Files
MobileGL/MobileGL/MG_IntegrationTest/Scenarios/PipeVerifyArmingScenario.cpp
T
swung0x48 ffc4fb7d77 [MG_Impl, MG_IntegrationTest, ci] (P7): the first perturbation block's falsifier is the DirectVulkan entry
- measured with one block removed at a time: without the re-applied block both server halves drop to 1 (DirectGLES 1 / DirectVulkan 1, both entries red); without the first block DirectVulkan drops to 1 (red) while DirectGLES stays at 2 (green), because the GLES backend reads the pack field twice after the neutral pack is installed (3 = the saved-pack read + 2 post-install) and DirectVulkan once (2 = 1 + 1)
- so the >= 2 requirement falsifies the re-applied block on both backends and the first block on DirectVulkan; the CI step runs both backends and needs both, which it already does
- the scenario, hook and CI comments now say exactly that, instead of "removing either block drops it to 1"
2026-09-22 23:02:08 -04:00

400 lines
24 KiB
C++

// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/PipeVerifyArmingScenario.cpp
// 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
//
// Scenario - THE MOBILEGL_PIPE_VERIFY COMPARATOR IS ARMED, AND SAYS SO, AND CAN GO RED.
//
// The third CI mode (ARCHITECTURE.md 13.2-(2)) runs the whole integration suite with two state
// models in one address space: the PipeInputs block the frontend fills at every verb boundary,
// and a SnapshotFromGLContext() taken from the live GLContext. A green run of that mode is only
// worth something if the comparator was actually RUNNING - and "MOBILEGL_PIPE_VERIFY=1 against a
// library that was not built with -DMOBILEGL_PIPE_VERIFY=ON" is a no-op that looks exactly like a
// clean pass. That is the failure mode this scenario exists to make impossible:
//
// Armed - the environment says the comparator is on for this process, so the
// library must SAY it armed. It asserts a library observable against
// the environment, the same shape UnlocatedIoBlockScenario's arming
// case and AsyncCompileScenario::ExtensionStringMatchesTheConfiguration
// use. A lane whose library never armed FAILS here; it never passes.
// CorruptedFieldIsReported - the negative control for the comparator itself (gate G4). With
// MOBILEGL_PIPE_VERIFY_CORRUPT naming a field, the snapshot arm is
// perturbed before the entry compare, so a comparator that works must
// report Fatal{PipeVerifyDiffer, "<Field>@<Verb>"}. A comparator that
// compares nothing stays quiet and this case goes red.
// CorruptedFieldIsReportedOnTheServerRead
// - the same control aimed at the OTHER comparator (P7 wave 3, V1).
// The entry compare runs on the client thread at a verb boundary; the
// compare-at-read hook runs on the server's apply thread at every
// backend read, and until this case existed nothing could tell a hook
// that had stopped comparing from a hook with nothing to say. It
// reads the SERVER role's half of the log, because with the knob
// armed the client reports the same field and a union search would be
// satisfied by the arm that is not under test.
//
// The observable is the library's own log, because MG_Config is not reachable from this module
// (on Android it links the SHIPPING libMobileGL.so, built -fvisibility=hidden) and the arming
// signal is a latched MGLOG_I. The ctest entry sets MOBILEGL_LOG_FILE_PATH; this only reads it.
//
// Note on scope, and why Armed runs in a lane of its own. The log file is opened with
// fopen(path, "w") at the first log write of a process (MG_Util/Debug/Log.cpp, InitFile), so each
// process TRUNCATES it. That is fine for one process and false for many: in the ambient Verify.
// lane, 400-odd sibling entries share the one MOBILEGL_LOG_FILE_PATH, and CI runs that lane with
// `ctest -j 4`, so a neighbour's bring-up can truncate the file between this case's draw and its
// read. Every existing scenario in this suite that reads the library log (UnlocatedIoBlockScenario,
// the primgen reroute, the point-size demotion) is registered in a FILTERED lane with a log path of
// its own for exactly that reason, and this case now follows them: it runs in the VerifyArming.
// entries, which set MGITEST_PIPE_ARMING_LANE=1 and their own log, and skips everywhere else.
//
// What that proves, stated honestly: the arming line is a property of (this library, this
// environment), not of an individual test body, and the VerifyArming. entry runs the same library
// with the same MOBILEGL_PIPE_VERIFY=1 as its ~400 ambient siblings. One process per backend is
// therefore the whole of the evidence available for "the lane armed" - the per-process claim the
// shared log CANNOT support, because it only ever holds the last writer.
//
// Within the process: the arming line is latched at the FIRST fill, which may be the harness
// bring-up rather than this test's draw, so the arming search is whole-file on purpose; the
// divergence search is restricted to the bytes this case appended, which is where a differ belongs.
#include <cstdint>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <string>
#include "../Harness/PipeStatsWindow.h"
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
// The three strings the comparator contracts to print (the brief's D8 reporting shape).
// They are spelled here once so a rename of either half is one compile-visible edit.
constexpr const char* kArmedLine = "MGPipe: verify armed";
constexpr const char* kDifferPrefix = "Fatal{PipeVerifyDiffer";
constexpr const char* kUnmigratedPrefix = "Fatal{UnmigratedPipeInput";
// Set by the VerifyArming. ctest entries and by nothing else. It is a HARNESS variable, not
// a library knob (hence the MGITEST_ prefix): the library never reads it. It exists because
// this case reads a log file, and a log file is a per-LANE resource - see the note at the
// top of the file.
constexpr const char* kArmingLaneMarker = "MGITEST_PIPE_ARMING_LANE";
constexpr const char* kVS = R"(#version 330 core
in vec2 aPos;
void main() { gl_Position = vec4(aPos, 0.0, 1.0); }
)";
constexpr const char* kFS = R"(#version 330 core
out vec4 o_color;
void main() { o_color = vec4(0.25, 0.5, 0.75, 1.0); }
)";
// Reads the environment the way MG_ConfigLoader does (ScenarioFixture.h documents the
// rule); a string knob is "set" when it is present and non-empty, which is exactly what
// MG_ConfigLoader's QueryEnvVariable turns into a non-empty Features member.
bool StringKnobIsSet(const char* name) {
const char* value = std::getenv(name);
return value != nullptr && *value != '\0';
}
// The number of LINES in `text` that carry `needle` and `where=read`: one per backend read
// the compare-at-read hook reported for that field and verb (ReportDivergence is a single
// MGLOG_F, so a report never spans lines and two reports never share one).
std::size_t CountReadReports(const std::string& text, const std::string& needle) {
std::size_t count = 0;
std::size_t pos = 0;
while (pos < text.size()) {
const std::size_t eol = text.find('\n', pos);
const std::string line =
text.substr(pos, eol == std::string::npos ? std::string::npos : eol - pos);
if (line.find(needle) != std::string::npos && line.find("where=read") != std::string::npos) {
++count;
}
if (eol == std::string::npos) break;
pos = eol + 1;
}
return count;
}
class PipeVerifyArmingScenario : public ScenarioTest {
protected:
// The library log this process is writing, or an empty path when none was configured.
static std::filesystem::path LibraryLogPath() {
// P6: the path is a BASE NAME and the library writes one log per role; PipeStatsWindow
// derives the suffix, so the rule lives in one place.
return std::filesystem::path(MGITest::PipeStatsWindow::LibraryLogPath());
}
// ONE MARK PER ROLE. The library writes a log per role, so "how long is the log
// right now" is two numbers; a single scalar applied to the concatenation would slide
// by whatever the other role wrote in between and start the read mid-line.
static MGITest::PipeStatsWindow::LogMark LibraryLogMark() {
return MGITest::PipeStatsWindow::MarkLaneLog();
}
// BOTH ROLES. The arming diagnostics this case looks for are emitted by the
// BACKEND, and under inproc the backend runs on the apply thread - the server role -
// so the line lands in the server's log. Reading only the client's found nothing and
// reported the emulation unarmed, which accused the product of a defect the reader
// had invented.
static std::string LibraryLogSince(const MGITest::PipeStatsWindow::LogMark& mark) {
return MGITest::PipeStatsWindow::ReadLaneLogSince(mark);
}
static std::string LibraryLog() { return MGITest::PipeStatsWindow::ReadLaneLog(); }
// One frame that crosses several verb boundaries: a clear (kClear), a draw (kDraw) and
// a readback (kReadback). Three of the nine fill classes, so an entry compare that only
// ran for one of them still has something to say.
void DrawOneFrame() {
HeadlessGL& gl = Gl();
std::string error;
const unsigned int program = CompileProgram(kVS, kFS, &error);
ASSERT_NE(program, 0u) << error;
static const float kQuad[] = {-1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f};
GLuint vao = 0;
GLuint vbo = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(kQuad), kQuad, GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr);
BindDefaultFramebuffer();
glViewport(0, 0, gl.Width(), gl.Height());
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glUseProgram(program);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
Rgba8 pixel{};
glReadPixels(gl.Width() / 2, gl.Height() / 2, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, &pixel);
glBindVertexArray(0);
glDeleteBuffers(1, &vbo);
glDeleteVertexArrays(1, &vao);
m_centre = pixel;
}
Rgba8 m_centre{};
};
// THE CASE THAT FAILS A LANE WHOSE LIBRARY NEVER ARMED.
//
// Every other entry in the integration-verify lane renders the same frames it renders in the
// ambient lane and would be just as green against a library with no comparator compiled in -
// which is precisely how a verify lane goes green having verified nothing. This case is the
// one that cannot: the environment pins MOBILEGL_PIPE_VERIFY=1, therefore the library must
// have said "MGPipe: verify armed" in its own log, and if it did not, the mode is not running.
TEST_F(PipeVerifyArmingScenario, Armed) {
if (!Ready()) return;
if (!StringKnobIsSet(kArmingLaneMarker)) {
GTEST_SKIP() << "this case reads the library's log file, so it runs in the VerifyArming. "
"lane, which owns a log path no other entry writes to. In the ambient "
"Verify. lane 400-odd entries share one path and each truncates it "
"(Log.cpp opens it \"w\"), so a whole-file read here would race a "
"neighbour under ctest -j 4. Set by the ctest entry, never by hand.";
}
if (AmbientQuirkFromEnvironment("MOBILEGL_PIPE_VERIFY") != AmbientQuirk::On) {
GTEST_SKIP() << "this case needs MOBILEGL_PIPE_VERIFY=1 for the whole process, which is "
"what the Verify. ctest entries set; with the variable unset the "
"comparator is dormant even in a build that compiled it in";
}
if (LibraryLogPath().empty()) {
GTEST_SKIP() << "MOBILEGL_PIPE_VERIFY is pinned on but MOBILEGL_LOG_FILE_PATH is not "
"set, so the library has nowhere to record that it armed; the Verify. "
"ctest entries set both";
}
if (StringKnobIsSet("MOBILEGL_PIPE_VERIFY_CORRUPT")) {
GTEST_SKIP() << "MOBILEGL_PIPE_VERIFY_CORRUPT is armed in this process, so a divergence "
"is the EXPECTED outcome and asserting on its absence here would be "
"backwards; the VerifyCorrupted. lane owns that half";
}
const MGITest::PipeStatsWindow::LogMark before = LibraryLogMark();
ASSERT_NO_FATAL_FAILURE(DrawOneFrame());
EXPECT_EQ(FirstGLError(), 0u);
// Whole file, not just the appended bytes: the arming line is latched at the FIRST fill
// of the process, which may already have happened during the harness bring-up. The file
// is truncated at this process's first log write, so it still carries nothing else.
const std::string whole = LibraryLog();
EXPECT_NE(whole.find(kArmedLine), std::string::npos)
<< "MOBILEGL_PIPE_VERIFY=1 is set for this process and a frame was cleared, drawn and "
"read back, and the library never reported arming the comparator. Either this "
"library was not built with -DMOBILEGL_PIPE_VERIFY=ON (in which case the whole lane "
"is verifying nothing), or the arming MGLOG_I is gone. Log:\n"
<< whole;
const std::string appended = LibraryLogSince(before);
EXPECT_EQ(appended.find(kDifferPrefix), std::string::npos)
<< "the comparator reported a push/pull divergence on an ordinary frame:\n"
<< appended;
EXPECT_EQ(appended.find(kUnmigratedPrefix), std::string::npos)
<< "a backend read a field the verb's fill table does not list (add the row to "
"MG_Pipe/FillPoints.def, never mark the field sticky):\n"
<< appended;
}
// NEGATIVE CONTROL A (gate G4): a deliberately corrupted snapshot field must turn a green
// verify run red, naming that field and the verb it diverged on.
//
// It runs in its own lane (VerifyCorrupted.) because the knob is process-wide, and with
// MOBILEGL_PIPE_VERIFY_FATAL=0 so the process survives its own divergence and this case can
// read the report back out of the log. The CI step that runs the SAME knob against the
// ambient lane - where FATAL keeps its default - asserts the other half: there, the
// divergence must abort and ctest must go red.
TEST_F(PipeVerifyArmingScenario, CorruptedFieldIsReported) {
if (!Ready()) return;
if (!StringKnobIsSet("MOBILEGL_PIPE_VERIFY_CORRUPT")) {
GTEST_SKIP() << "this case is the comparator's negative control and needs "
"MOBILEGL_PIPE_VERIFY_CORRUPT=<FieldName> for the whole process, which "
"is what the VerifyCorrupted. ctest entries set";
}
if (AmbientQuirkFromEnvironment("MOBILEGL_PIPE_VERIFY") != AmbientQuirk::On) {
GTEST_SKIP() << "MOBILEGL_PIPE_VERIFY_CORRUPT is set but MOBILEGL_PIPE_VERIFY is not, so "
"the comparator is dormant and there is nothing to corrupt";
}
if (LibraryLogPath().empty()) {
GTEST_SKIP() << "MOBILEGL_LOG_FILE_PATH is not set, so the library has nowhere to report "
"the divergence; the VerifyCorrupted. ctest entries set both";
}
const std::string knob = std::getenv("MOBILEGL_PIPE_VERIFY_CORRUPT");
const MGITest::PipeStatsWindow::LogMark before = LibraryLogMark();
ASSERT_NO_FATAL_FAILURE(DrawOneFrame());
const std::string appended = LibraryLogSince(before);
const std::string expected = std::string(kDifferPrefix) + ", \"" + knob + "@";
EXPECT_NE(appended.find(expected), std::string::npos)
<< "MOBILEGL_PIPE_VERIFY_CORRUPT=" << knob
<< " perturbs that field in the snapshot arm before every entry compare, so a working "
"comparator must have reported " << expected << "...\". It reported nothing, which "
"means the comparator is not comparing - and every green entry in this lane is "
"green for no reason. Log appended by this case:\n"
<< appended;
}
// NEGATIVE CONTROL A ON THE OTHER ARM (P7 wave 3, V1): the COMPARE-AT-READ hook, on the
// SERVER's apply thread, inside the ID-49 neutral pack window.
//
// WHY THE CASE ABOVE DOES NOT COVER IT. MOBILEGL_PIPE_VERIFY_CORRUPT used to perturb only
// EntryCompare's snapshot, so every red it could produce came from the CLIENT thread at a
// verb boundary and said `where=entry`. The hook that runs on every backend read - the
// whole of the split lane's per-read work, and the only comparator the server's apply
// thread ever runs - had no falsifier: a hook that had stopped comparing looked exactly
// like a hook with nothing to report, and the lane would have been just as green.
//
// WHY THIS FIELD AND THIS VERB. GetPixelStoreParameters@ReadPixels is where the hook does
// its one piece of ARM-SPECIFIC reasoning (PipeFill.cpp's
// ServerReadsInsideTheNeutralPackWindow): the applier reads the backend with the neutral
// pack, so inside that window the oracle for the pack half is the neutral pack rather than
// the live context. A control aimed anywhere else would leave exactly that branch unproven.
//
// THE SERVER HALF, NOT THE UNION. With this knob armed the client's own entry compare
// reports `GetPixelStoreParameters@...` too, in the client's log, so a whole-lane search
// would be satisfied without the server having run anything at all. The assertion below
// reads the server role's half and nothing else.
//
// MOBILEGL_PIPE_VERIFY_FATAL=0, for CorruptedFieldIsReported's reason and one more: the
// client's entry compare on the ReadPixels verb fires FIRST (kReadback's fill mask carries
// this field, FillPoints.def), so with FATAL at its default the process would abort before
// the server ever reached the read.
TEST_F(PipeVerifyArmingScenario, CorruptedFieldIsReportedOnTheServerRead) {
if (!Ready()) return;
constexpr const char* kPackField = "GetPixelStoreParameters";
const char* knob = std::getenv("MOBILEGL_PIPE_VERIFY_CORRUPT");
if (knob == nullptr || std::string(knob) != kPackField) {
GTEST_SKIP() << "this case is the compare-at-read hook's negative control and needs "
"MOBILEGL_PIPE_VERIFY_CORRUPT=" << kPackField << " for the whole "
"process, which is what the VerifySplitReadCorrupted. ctest entries "
"set; no other field is read by the server inside a window whose "
"oracle the hook rewrites";
}
if (AmbientQuirkFromEnvironment("MOBILEGL_PIPE_VERIFY") != AmbientQuirk::On) {
GTEST_SKIP() << "MOBILEGL_PIPE_VERIFY_CORRUPT is set but MOBILEGL_PIPE_VERIFY is not, so "
"the comparator is dormant and there is nothing to corrupt";
}
if (LibraryLogPath().empty()) {
GTEST_SKIP() << "MOBILEGL_LOG_FILE_PATH is not set, so the library has nowhere to report "
"the divergence; the VerifySplitReadCorrupted. ctest entries set both";
}
if (MGITest::PipeStatsWindow::ServerLibraryLogPath().empty()) {
GTEST_SKIP() << "this build writes no server-role log, so there is no half in which the "
"server's own report could be told apart from the client's; the hook's "
"read arm is registered on the split (inproc) lane only";
}
const MGITest::PipeStatsWindow::LogMark before = LibraryLogMark();
ASSERT_NO_FATAL_FAILURE(DrawOneFrame());
const std::string server = MGITest::PipeStatsWindow::ReadServerLogSince(before);
const std::string expected =
std::string(kDifferPrefix) + ", \"" + kPackField + "@ReadPixels\"";
const std::size_t at = server.find(expected);
ASSERT_NE(at, std::string::npos)
<< "MOBILEGL_PIPE_VERIFY_CORRUPT=" << kPackField << " perturbs the hook's ORACLE at "
"every backend read of that field, and the server's read_pixels reads it under "
"the server's own stamp - so the server role's log had to carry " << expected
<< ", ...}. It carried nothing: either the compare-at-read hook is not running on "
"the apply thread, or the neutral-pack window swallowed the perturbation, and "
"in both cases every per-read green in this lane is green for no reason. Server "
"half of the log appended by this case:\n"
<< server;
const std::size_t eol = server.find('\n', at);
const std::string line =
server.substr(at, eol == std::string::npos ? std::string::npos : eol - at);
EXPECT_NE(line.find("where=read"), std::string::npos)
<< "the server half reported the corrupted field, but not from the compare-at-read "
"arm - `where=` says which comparator spoke, and only `read` is this case's "
"subject. Line:\n"
<< line;
// TWO REPORTS, NOT ONE (V1 fix round 2) - the count is what makes BOTH perturbation
// blocks in the hook load-bearing. The server reads this field twice inside the
// ReadPixels window, and both reads sit inside the hook's neutral-pack branch:
// PipeApplier::read_pixels saves the application's pack BEFORE it installs the neutral
// one, and the backend's ReadPixels reads the field AFTER. The hook's first perturbation
// is what turns the saved-pack read red (past the first compare, the application's
// pack against the neutral oracle differs on its own); only the perturbation re-applied
// after the window's overwrite can turn the post-install read red, because there the
// stored value IS the neutral pack. A control satisfied by ONE line is satisfied by
// either block alone and so falsified neither. Measured at landing the server half
// carries 3 (DirectGLES: the saved-pack read and TWO post-install reads) / 2
// (DirectVulkan: one of each). Removing the re-applied block drops both backends to
// 1; removing the first block drops DirectVulkan to 1 and leaves DirectGLES at 2, so
// the first block's falsifier is the DirectVulkan entry - which is why the CI step
// runs both backends and needs both.
const std::size_t reads = CountReadReports(server, expected);
EXPECT_GE(reads, std::size_t{2})
<< "the server half carries " << reads << " `where=read` report(s) for " << expected
<< ", ...}. The server reads that field twice inside the ReadPixels window (the "
"applier's saved-pack read and the backend's post-install read) and the hook "
"perturbs the oracle once per read, so fewer than two means one perturbation has "
"stopped reaching its read. Server half of the log appended by this case:\n"
<< server;
}
} // namespace
} // namespace MGITest