From bdf05514c348391c2ae84dff29a6cb4490678a59 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 01:57:05 -0400 Subject: [PATCH] [Test] (Pipe): the integration-verify lanes and their two always-on negative controls - ARCHITECTURE.md 13.2-(2) asks for a third CI mode, and a third mode whose only evidence is "ctest was green" proves nothing: MOBILEGL_PIPE_VERIFY=1 against a library that never compiled the comparator in is a silent no-op that looks exactly like a clean pass. Six registrations, all under if (MOBILEGL_PIPE_VERIFY) and all labelled integration-verify, make both halves falsifiable - a mis-configured build registers nothing and --no-tests=error reds the lane, and PipeVerifyArmingScenario.Armed fails a lane whose library never printed its arming line. - PipeVerifyArmingScenario.CorruptedFieldIsReported is negative control A (G4): its lane pins MOBILEGL_PIPE_VERIFY_CORRUPT=GetRenderStateParameters with MOBILEGL_PIPE_VERIFY_FATAL=0 so the process survives its own divergence and can read the report back; the CI step that exports the same knob against the ambient lane, where FATAL keeps its default, asserts the other half - the abort. - PoisonOmissionScenario is negative control B (G5), in two cases that cannot share a process because the knob is process-wide: the omitted (verb, field) pair must abort the glGenerateMipmap and NOT the draw before it, and the same sequence with the knob unset must complete with no Fatal at all. - The sequence runs in a fork()+execve() of this same binary rather than a bare fork(): the fixture has already brought a context up, and a bare fork of a process holding a live Vulkan device inherits the driver's mutexes with no threads to release them - measured here as a 120s wedge on DirectVulkan against a clean pass on DirectGLES. The child gets its own MOBILEGL_LOG_FILE_PATH because the library opens its log with fopen(path, "w") and would otherwise truncate the file the parent is about to read. - No ambient Verify. entry names MOBILEGL_PIPE_VERIFY_CORRUPT or MOBILEGL_PIPE_POISON_OMIT in its ENVIRONMENT property, because a property entry overrides the job environment for the names it lists: the two CI negative-control steps export those knobs into the job environment and must reach the processes. Every list appends MGL_ITEST_COMMON_ENV / MGL_ITEST_VULKAN_ENV for the same reason, so the vendor and ICD pinning survives. --- MobileGL/MG_IntegrationTest/CMakeLists.txt | 134 ++++++ .../Scenarios/PipeVerifyArmingScenario.cpp | 243 +++++++++++ .../Scenarios/PoisonOmissionScenario.cpp | 385 ++++++++++++++++++ 3 files changed, 762 insertions(+) create mode 100644 MobileGL/MG_IntegrationTest/Scenarios/PipeVerifyArmingScenario.cpp create mode 100644 MobileGL/MG_IntegrationTest/Scenarios/PoisonOmissionScenario.cpp diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 83e93597..c4bc3d4a 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -127,6 +127,8 @@ add_executable(MobileGLIntegrationTest Scenarios/ClearTexImageUndefinedLevelZeroScenario.cpp Scenarios/RenderbufferBlendFormatScenario.cpp Scenarios/DualSourceBlendScenario.cpp + Scenarios/PipeVerifyArmingScenario.cpp + Scenarios/PoisonOmissionScenario.cpp ) target_include_directories(MobileGLIntegrationTest PRIVATE @@ -631,3 +633,135 @@ gtest_discover_tests(MobileGLIntegrationTest TIMEOUT ${MGL_ITEST_TIMEOUT} ENVIRONMENT "${MGL_ITEST_VULKAN_POINT_SIZE_DEMOTION_ENVIRONMENT}" ) + +# --- the third CI mode: MOBILEGL_PIPE_VERIFY ----------------------------------- +# +# ARCHITECTURE.md 13.2-(2) asks for a THIRD build mode next to pull and push: two state models in +# one address space, compared field by field at every verb boundary and again at every accessor +# read, 5-10x slower and never shipped. These entries are that mode's lane. They exist only when +# the library was configured with -DMOBILEGL_PIPE_VERIFY=ON, which is deliberate and is half of +# what makes the lane falsifiable: `ctest -L integration-verify --no-tests=error` in a build that +# forgot the option matches NO tests and fails, instead of reporting a green run of nothing. +# +# The other half is PipeVerifyArmingScenario.Armed, which asserts the library's own arming line - +# because MOBILEGL_PIPE_VERIFY=1 in the environment of a library that never compiled the +# comparator in is a silent no-op that looks exactly like a clean pass. +# +# Three things about the ENVIRONMENT properties below, each of which has already gone wrong once +# in this file: +# * every list APPENDS ${MGL_ITEST_COMMON_ENV} / ${MGL_ITEST_VULKAN_ENV}. A ctest ENVIRONMENT +# entry overrides the job environment for the names it lists, so an entry that named only its +# own knobs would lose the EGL vendor and Vulkan ICD pinning and run against whichever driver +# the loader found first. +# * the ambient Verify. entries name NEITHER MOBILEGL_PIPE_VERIFY_CORRUPT NOR +# MOBILEGL_PIPE_POISON_OMIT. That is what lets CI's two always-on negative-control steps +# export those knobs in the JOB environment and have them reach the test processes; a +# property entry of the same name would silently win and the controls would prove nothing. +# * MOBILEGL_LOG_FILE_PATH is per lane. It is the only channel a test process has for reading +# the library's own report (MG_Config is not reachable from this module), and the log is +# opened with fopen(path, "w"), so each process truncates it and the cases can trust it. +if (MOBILEGL_PIPE_VERIFY) + # 900s, not the ambient 120: the comparator re-reads every field of the fill mask at the verb + # boundary and again at every accessor read, which the design budgets at 5-10x. + set(MGL_ITEST_VERIFY_TIMEOUT 900) + + mgl_itest_join_environment(MGL_ITEST_GLES_VERIFY_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_PIPE_VERIFY=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/pipe-verify-DirectGLES.log" + ${MGL_ITEST_COMMON_ENV}) + mgl_itest_join_environment(MGL_ITEST_VULKAN_VERIFY_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_PIPE_VERIFY=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/pipe-verify-DirectVulkan.log" + ${MGL_ITEST_VULKAN_ENV}) + + # Negative control A (G4). MOBILEGL_PIPE_VERIFY_FATAL=0 so the process SURVIVES its own + # divergence and the case can read the report back out of the log; the CI step that exports + # the same corruption against the ambient lane, where FATAL keeps its default of 1, asserts + # the other half - that a divergence aborts and reds the entry. + mgl_itest_join_environment(MGL_ITEST_GLES_VERIFY_CORRUPT_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_PIPE_VERIFY=1" + "MOBILEGL_PIPE_VERIFY_CORRUPT=GetRenderStateParameters" "MOBILEGL_PIPE_VERIFY_FATAL=0" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/pipe-verify-corrupt-DirectGLES.log" + ${MGL_ITEST_COMMON_ENV}) + mgl_itest_join_environment(MGL_ITEST_VULKAN_VERIFY_CORRUPT_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_PIPE_VERIFY=1" + "MOBILEGL_PIPE_VERIFY_CORRUPT=GetRenderStateParameters" "MOBILEGL_PIPE_VERIFY_FATAL=0" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/pipe-verify-corrupt-DirectVulkan.log" + ${MGL_ITEST_VULKAN_ENV}) + + # Negative control B (G5). The omission skips the STAMP of one field for one verb while still + # copying its value, which is indistinguishable from a fill row nobody wrote; the scenario + # forks, so the resulting std::abort() is a datum in waitpid() rather than a dead lane. + mgl_itest_join_environment(MGL_ITEST_GLES_POISON_OMIT_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_PIPE_VERIFY=1" + "MOBILEGL_PIPE_POISON_OMIT=GenerateMipmap:GetActiveTextureUnit" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/pipe-poison-omit-DirectGLES.log" + ${MGL_ITEST_COMMON_ENV}) + mgl_itest_join_environment(MGL_ITEST_VULKAN_POISON_OMIT_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_PIPE_VERIFY=1" + "MOBILEGL_PIPE_POISON_OMIT=GenerateMipmap:GetActiveTextureUnit" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/pipe-poison-omit-DirectVulkan.log" + ${MGL_ITEST_VULKAN_ENV}) + + # The whole suite again, per backend, with the comparator armed. Same scenarios, same + # assertions, but every backend read of frontend state is now checked against a snapshot taken + # from the live context at the verb boundary - which is what "the 742 integration entries + # prove push equals pull" means. Labelled integration-gpu as well so a verify build's + # `ctest -L integration-gpu` still describes the whole registration set. + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.Verify." + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-verify" + TIMEOUT ${MGL_ITEST_VERIFY_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_VERIFY_ENVIRONMENT}" + ) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.Verify." + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-verify" + TIMEOUT ${MGL_ITEST_VERIFY_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_VERIFY_ENVIRONMENT}" + ) + + # One case each: the knobs are process-wide, so a corrupted or poisoned process cannot also be + # running the ambient assertions. These four entries are the ones that assert the RED - they + # pass when the comparator and the poison report, and go red when either stops. + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.VerifyCorrupted." + TEST_FILTER "PipeVerifyArmingScenario.CorruptedFieldIsReported" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-verify" + TIMEOUT ${MGL_ITEST_VERIFY_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_VERIFY_CORRUPT_ENVIRONMENT}" + ) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.VerifyCorrupted." + TEST_FILTER "PipeVerifyArmingScenario.CorruptedFieldIsReported" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-verify" + TIMEOUT ${MGL_ITEST_VERIFY_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_VERIFY_CORRUPT_ENVIRONMENT}" + ) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.PoisonOmitted." + TEST_FILTER "PoisonOmissionScenario.OmittedFieldAbortsOnThatVerb" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-verify" + TIMEOUT ${MGL_ITEST_VERIFY_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_POISON_OMIT_ENVIRONMENT}" + ) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.PoisonOmitted." + TEST_FILTER "PoisonOmissionScenario.OmittedFieldAbortsOnThatVerb" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-verify" + TIMEOUT ${MGL_ITEST_VERIFY_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_POISON_OMIT_ENVIRONMENT}" + ) +endif() diff --git a/MobileGL/MG_IntegrationTest/Scenarios/PipeVerifyArmingScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/PipeVerifyArmingScenario.cpp new file mode 100644 index 00000000..5ce95138 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/PipeVerifyArmingScenario.cpp @@ -0,0 +1,243 @@ +// 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, "@"}. A comparator that +// compares nothing stays quiet and this case goes red. +// +// 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: the log file is opened with fopen(path, "w") at the first log write of a process +// (MG_Util/Debug/Log.cpp, InitFile), so the file holds THIS process's lines and nothing else - a +// whole-file search cannot be satisfied by a sibling ctest entry of the same lane. The arming line +// is latched at the FIRST fill of the process, 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 +#include +#include +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#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"; + + 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'; + } + + 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() { + const char* path = std::getenv("MOBILEGL_LOG_FILE_PATH"); + return (path != nullptr && *path != '\0') ? std::filesystem::path(path) + : std::filesystem::path(); + } + + static std::uintmax_t LibraryLogSize() { + std::error_code ec; + const std::filesystem::path path = LibraryLogPath(); + if (path.empty()) return 0; + const std::uintmax_t size = std::filesystem::file_size(path, ec); + return ec ? 0 : size; + } + + static std::string LibraryLogSince(std::uintmax_t offset) { + const std::filesystem::path path = LibraryLogPath(); + if (path.empty()) return {}; + std::ifstream file(path, std::ios::binary); + if (!file.good()) return {}; + file.seekg(static_cast(offset)); + return std::string((std::istreambuf_iterator(file)), std::istreambuf_iterator()); + } + + static std::string LibraryLog() { return LibraryLogSince(0); } + + // 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 (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 std::uintmax_t before = LibraryLogSize(); + 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= 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 std::uintmax_t before = LibraryLogSize(); + 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; + } + + } // namespace +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/PoisonOmissionScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/PoisonOmissionScenario.cpp new file mode 100644 index 00000000..2275ada6 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/PoisonOmissionScenario.cpp @@ -0,0 +1,385 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/PoisonOmissionScenario.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 - NEGATIVE CONTROL B (gate G5): AN OMITTED FILL POINT ABORTS ON THAT VERB, AND ONLY THERE. +// +// The per-verb poison is the half of P1 that makes a forgotten fill row loud instead of silent: the +// filler stamps a generation on every field it copies for a verb, and an accessor whose stamp is not +// this verb's aborts with Fatal{UnmigratedPipeInput, "@"}. A mechanism that can only be +// observed when someone forgets a row is a mechanism nobody can trust, so MOBILEGL_PIPE_POISON_OMIT +// forges the mistake on purpose: it names one (verb, field) pair whose STAMP the filler skips while +// still copying the value, which is indistinguishable from a row that was never written. +// +// The scenario asserts both halves of "on THAT verb, and only there": +// +// OmittedFieldAbortsOnThatVerb - with MOBILEGL_PIPE_POISON_OMIT=GenerateMipmap:GetActiveTextureUnit, +// a draw must still complete (GetActiveTextureUnit is not in kDraw's +// mask, and the draw's own fields are stamped normally) and the +// following glGenerateMipmap must abort naming exactly that pair. +// WithoutOmissionCompletes - the identical sequence with the knob unset runs to completion with +// no Fatal at all. Without this half, "it aborted" would say nothing +// 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). +// +// 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 +// sequence cannot run in the test process, and the harness's own bring-up pre-flight +// (Harness/HeadlessGL.cpp) already establishes the shape: run it where a SIGABRT is a datum in +// waitpid() instead of a dead lane. But that pre-flight forks BEFORE any context exists, and this case +// cannot: the fixture has already brought one up. A bare fork() of a process holding a live Vulkan +// device inherits the driver's mutexes with no threads to release them, and the child wedges on its +// first submit - measured here as a 120s timeout on DirectVulkan and a clean pass on DirectGLES, which +// is exactly the kind of backend-shaped flake a control must not have. So the child immediately +// execve()s a fresh copy of this same test binary, filtered to the worker case below, which brings up +// its own context from scratch and knows nothing about the parent's. +// +// The child gets its OWN MOBILEGL_LOG_FILE_PATH for the same reason: the library opens its log with +// fopen(path, "w"), so a child sharing the parent's path would truncate the file the parent is about +// to read. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#if !defined(_WIN32) && !defined(__APPLE__) && !defined(__ANDROID__) && __has_include() +#define MGITEST_POISON_HAVE_FORK 1 +#include +#include +#include +#include +#include +extern char** environ; +#else +#define MGITEST_POISON_HAVE_FORK 0 +#endif + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + // What the PoisonOmitted. ctest entry and the CI negative-control step name. The pair is + // spelled here so the assertion below is about the exact string the poison contracts to + // print (ARCHITECTURE.md 9.2: Fatal{UnmigratedPipeInput, "@"}). + constexpr const char* kOmittedVerb = "GenerateMipmap"; + constexpr const char* kOmittedField = "GetActiveTextureUnit"; + constexpr const char* kFatalPrefix = "Fatal{UnmigratedPipeInput"; + + // Set only in the re-executed child, so the worker case below runs in that process and skips + // everywhere else (including in the ambient lanes, where it is registered like any other case). + constexpr const char* kChildMarker = "MGITEST_POISON_OMISSION_CHILD"; + constexpr const char* kWorkerFilter = + "--gtest_filter=PoisonOmissionScenario.TheSequenceThePoisonControlsRun"; + + 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); } +)"; + + bool StringKnobIsSet(const char* name) { + const char* value = std::getenv(name); + return value != nullptr && *value != '\0'; + } + + std::filesystem::path LibraryLogPath() { + const char* path = std::getenv("MOBILEGL_LOG_FILE_PATH"); + return (path != nullptr && *path != '\0') ? std::filesystem::path(path) + : std::filesystem::path(); + } + + // Where the child is told to write ITS log. Empty when the lane configured no log path at + // all, in which case the signal is the only evidence and the text assertions are skipped. + std::string ChildLogPath() { + const std::filesystem::path parent = LibraryLogPath(); + if (parent.empty()) return {}; + return (parent.string() + ".poison-child"); + } + + std::string ReadWholeFile(const std::string& path) { + if (path.empty()) return {}; + std::ifstream file(path, std::ios::binary); + if (!file.good()) return {}; + return std::string((std::istreambuf_iterator(file)), std::istreambuf_iterator()); + } + + class PoisonOmissionScenario : public ScenarioTest { + protected: + // The sequence under test. Deliberately in this order: the DRAW comes first and must + // survive - if the poison fired there, the "only that verb" half would be false and the + // SIGABRT the parent waits for would prove nothing. + void RunSequence() { + HeadlessGL& gl = Gl(); + + std::string error; + const unsigned int program = CompileProgram(kVS, kFS, &error); + ASSERT_NE(program, 0u) << error; + + // A two-level texture, so glGenerateMipmap has real work to do and cannot be + // short-circuited into a no-op by a backend that inspects the level count first. + GLuint texture = 0; + glGenTextures(1, &texture); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, texture); + unsigned char pixels[8 * 8 * 4]; + for (std::size_t i = 0; i < sizeof(pixels); ++i) { + pixels[i] = static_cast(i); + } + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 8, 8, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels); + glTexImage2D(GL_TEXTURE_2D, 1, GL_RGBA8, 4, 4, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 3); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); + + 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); + glFinish(); + std::fprintf(stderr, "[itest] poison worker: the draw completed\n"); + + // The verb the omission names. Under MOBILEGL_PIPE_POISON_OMIT this must abort. + glBindTexture(GL_TEXTURE_2D, texture); + glGenerateMipmap(GL_TEXTURE_2D); + glFinish(); + std::fprintf(stderr, "[itest] poison worker: glGenerateMipmap returned\n"); + + glBindVertexArray(0); + glDeleteBuffers(1, &vbo); + glDeleteVertexArrays(1, &vao); + glDeleteTextures(1, &texture); + } + +#if MGITEST_POISON_HAVE_FORK + // fork() + execve() of this same binary, filtered to the worker case, with the marker and + // the child's own log path added to the environment. Everything that allocates happens + // BEFORE the fork; between fork and execve only async-signal-safe work is done. + static bool RunSequenceInAChildProcess(int& outStatus, std::string& outReason) { + std::vector env; + for (char** entry = environ; entry != nullptr && *entry != nullptr; ++entry) { + const std::string text(*entry); + if (text.rfind("MOBILEGL_LOG_FILE_PATH=", 0) == 0) continue; + if (text.rfind(std::string(kChildMarker) + "=", 0) == 0) continue; + env.push_back(text); + } + env.push_back(std::string(kChildMarker) + "=1"); + const std::string childLog = ChildLogPath(); + if (!childLog.empty()) { + std::error_code ec; + std::filesystem::remove(childLog, ec); + env.push_back("MOBILEGL_LOG_FILE_PATH=" + childLog); + } + + std::vector envp; + envp.reserve(env.size() + 1); + for (std::string& entry : env) envp.push_back(entry.data()); + envp.push_back(nullptr); + + std::string exe = "/proc/self/exe"; + std::string arg0 = "MobileGLIntegrationTest"; + std::string filter = kWorkerFilter; + char* argv[] = {arg0.data(), filter.data(), nullptr}; + + std::fflush(nullptr); + const pid_t child = fork(); + if (child < 0) { + outReason = "fork() failed"; + return false; + } + if (child == 0) { + execve(exe.c_str(), argv, envp.data()); + // execve only returns on failure; _exit, never exit(), because every atexit + // handler in this address space belongs to the parent's copy of the world. + std::fprintf(stderr, "[itest] poison child: execve(/proc/self/exe) failed\n"); + _exit(127); + } + + constexpr int kTimeoutMs = 120000; + int waitedMs = 0; + for (;;) { + const pid_t reaped = waitpid(child, &outStatus, WNOHANG); + if (reaped == child) return true; + if (reaped < 0) { + outReason = "waitpid on the poison worker failed"; + return false; + } + if (waitedMs >= kTimeoutMs) { + kill(child, SIGKILL); + (void)waitpid(child, &outStatus, 0); + outReason = "the poison worker made no progress in 120s and was killed"; + return false; + } + timespec nap{0, 10 * 1000 * 1000}; + nanosleep(&nap, nullptr); + waitedMs += 10; + } + } + + static std::string DescribeStatus(int status) { + if (WIFEXITED(status)) return "exited with status " + std::to_string(WEXITSTATUS(status)); + if (WIFSIGNALED(status)) return "died on signal " + std::to_string(WTERMSIG(status)); + return "ended in an unrecognised way"; + } +#endif + }; + + // The worker. It is a normal registered case so that the re-executed child can be selected + // with nothing but --gtest_filter, and it skips in every process that is not that child. + TEST_F(PoisonOmissionScenario, TheSequenceThePoisonControlsRun) { + if (std::getenv(kChildMarker) == nullptr) { + GTEST_SKIP() << "this case is the body the two poison controls run in a child process; " + "it does nothing unless " << kChildMarker << " is set, which only the " + "re-exec below does"; + } + if (!Ready()) return; + + RunSequence(); + +#if MGITEST_POISON_HAVE_FORK + // _exit, and not a return into gtest's teardown: this process exists to reach the verb + // above and its exit status is the datum the parent reads. A normal teardown of a live + // context could add signals of its own to that answer. + std::fflush(nullptr); + _exit(0); +#endif + } + +#if MGITEST_POISON_HAVE_FORK + + TEST_F(PoisonOmissionScenario, OmittedFieldAbortsOnThatVerb) { + if (!Ready()) return; + + if (!StringKnobIsSet("MOBILEGL_PIPE_POISON_OMIT")) { + GTEST_SKIP() << "this case is the poison's negative control and needs " + "MOBILEGL_PIPE_POISON_OMIT=: for the whole process, which " + "is what the PoisonOmitted. ctest entries set"; + } + const std::string knob = std::getenv("MOBILEGL_PIPE_POISON_OMIT"); + const std::string expectedPair = std::string(kOmittedField) + "@" + kOmittedVerb; + if (knob != std::string(kOmittedVerb) + ":" + kOmittedField) { + GTEST_SKIP() << "MOBILEGL_PIPE_POISON_OMIT is " << knob << ", but this case only knows " + << "how to provoke " << kOmittedVerb << ":" << kOmittedField; + } + + int status = 0; + std::string reason; + ASSERT_TRUE(RunSequenceInAChildProcess(status, reason)) << reason; + + const std::string childLog = ReadWholeFile(ChildLogPath()); + ASSERT_TRUE(WIFSIGNALED(status)) + << "with the stamp of " << expectedPair << " omitted, the glGenerateMipmap in the child " + << "had to read a field its verb never filled and abort. It " << DescribeStatus(status) + << " instead - the poison is not armed (a build without MOBILEGL_PIPE_POISON, a filler " + "that stamps what it was told to skip, or a backend that no longer reads the field " + "through the accessor). Child log:\n" + << childLog; + EXPECT_EQ(WTERMSIG(status), SIGABRT) + << "the child died on signal " << WTERMSIG(status) << " rather than SIGABRT; the poison " + "reports through MGLOG_F + std::abort(), so any other signal is a different crash. " + "Child log:\n" + << childLog; + + if (ChildLogPath().empty()) { + GTEST_SKIP() << "the abort happened, but the lane set no MOBILEGL_LOG_FILE_PATH, so the " + "Fatal's text cannot be read back; the PoisonOmitted. ctest entries set it"; + } + EXPECT_NE(childLog.find(std::string(kFatalPrefix) + ", \"" + expectedPair + "\""), + std::string::npos) + << "the child aborted, but not with Fatal{UnmigratedPipeInput, \"" << expectedPair + << "\"} - that message is the whole diagnostic value of the poison. Child log:\n" + << childLog; + EXPECT_EQ(childLog.find("@DrawArrays"), std::string::npos) + << "the draw that ran BEFORE the omitted verb also tripped the poison, so the omission " + "is not scoped to its verb: the fill classes are wrong, or the stamps are global. " + "Child log:\n" + << childLog; + } + + // 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. + TEST_F(PoisonOmissionScenario, WithoutOmissionCompletes) { + if (!Ready()) return; + + if (AmbientQuirkFromEnvironment("MOBILEGL_PIPE_VERIFY") != AmbientQuirk::On) { + GTEST_SKIP() << "the poison is only compiled into the push/verify builds; in an ordinary " + "build there is nothing for this control to be a control OF"; + } + if (StringKnobIsSet("MOBILEGL_PIPE_POISON_OMIT")) { + GTEST_SKIP() << "MOBILEGL_PIPE_POISON_OMIT is armed for this process, so the abort is the " + "EXPECTED outcome here; OmittedFieldAbortsOnThatVerb owns that half and " + "runs in the PoisonOmitted. lane"; + } + + int status = 0; + std::string reason; + ASSERT_TRUE(RunSequenceInAChildProcess(status, reason)) << reason; + + const std::string childLog = ReadWholeFile(ChildLogPath()); + ASSERT_TRUE(WIFEXITED(status)) + << "with no omission armed, a draw followed by glGenerateMipmap must complete; the child " + << DescribeStatus(status) + << ". If it aborted, the poison is firing on a field the verb's fill table SHOULD list - " + "add the row to MG_Pipe/FillPoints.def, never mark the field sticky. Child log:\n" + << childLog; + EXPECT_EQ(WEXITSTATUS(status), 0) << "the child " << DescribeStatus(status) + << ". Child log:\n" + << childLog; + EXPECT_EQ(childLog.find("Fatal{"), std::string::npos) + << "an unpoisoned run logged a Fatal:\n" + << childLog; + } + +#else + + TEST_F(PoisonOmissionScenario, OmittedFieldAbortsOnThatVerb) { + GTEST_SKIP() << "the poison control needs fork()/execve()/waitpid() to observe a SIGABRT as " + "a datum; this platform has none of them"; + } + + TEST_F(PoisonOmissionScenario, WithoutOmissionCompletes) { + GTEST_SKIP() << "the poison control needs fork()/execve()/waitpid() to observe a SIGABRT as " + "a datum; this platform has none of them"; + } + +#endif + + } // namespace +} // namespace MGITest