[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.
This commit is contained in:
2026-09-06 04:41:28 -04:00
parent bf8b39a867
commit bdf05514c3
3 changed files with 762 additions and 0 deletions
+134
View File
@@ -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()
@@ -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, "<Field>@<Verb>"}. 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 <cstdint>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <string>
#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";
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<std::streamoff>(offset));
return std::string((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
}
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=<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 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
@@ -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, "<Field>@<Verb>"}. 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 <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#if !defined(_WIN32) && !defined(__APPLE__) && !defined(__ANDROID__) && __has_include(<sys/wait.h>)
#define MGITEST_POISON_HAVE_FORK 1
#include <csignal>
#include <ctime>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
extern char** environ;
#else
#define MGITEST_POISON_HAVE_FORK 0
#endif
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#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, "<Field>@<Verb>"}).
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<char>(file)), std::istreambuf_iterator<char>());
}
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<unsigned char>(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<std::string> 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<char*> 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=<Verb>:<Field> 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