[Feat] (MG_Remote, P5): land the client role - BackendObject_Remote, the 71-slot emit table, the caps mirror and the verb barrier

This commit is contained in:
2026-09-11 16:46:24 -04:00
parent bfaf5f9d0e
commit d628d906d1
18 changed files with 2235 additions and 71 deletions
+419
View File
@@ -0,0 +1,419 @@
// MobileGL - MobileGL/MG_Test/Wire/RemoteClientTest.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
// P5 package c1's suite: the 71-slot emit table, the caps mirror and R-8's liveness gates. No
// session, no transport and no thread - s1's SessionTest owns those and w1's PipeWireCodecTest
// owns the bytes.
//
// IT LINKS gtest RATHER THAN gtest_main AND CARRIES ITS OWN main(), for PipeWireCodecTest's and
// PipeInputsTest's reason: the Fatal arms report through MGLOG_F + std::abort, and MGLOG_F
// writes to STDOUT and to a named file, NEVER to stderr - so EXPECT_DEATH's stderr regex could
// only ever match the empty string. A case that drives one FORKS and reads the Fatal line back
// out of a log file this process names before anything logs. That is what makes each control
// assert ITS OWN failure string (R-16) instead of asserting that something, somewhere, died.
#include <gtest/gtest.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include "Includes.h"
#include <Config.h>
#include <MG_Pipe/MGPipe.h>
#include <MG_Remote/CapsCodec.h>
#include <MG_Remote/Client/CapsMirror.h>
#include <MG_Remote/Client/EmitTables.h>
#if !defined(_WIN32)
#include <csignal>
#include <sys/wait.h>
#include <unistd.h>
#define MGTEST_HAVE_FORK 1
#else
#define MGTEST_HAVE_FORK 0
#endif
using namespace MobileGL;
using namespace MobileGL::MG_Pipe;
using namespace MobileGL::MG_Remote;
using namespace MobileGL::MG_Remote::Client;
namespace {
std::string g_logPath;
std::string ReadLog() {
std::ifstream in(g_logPath, std::ios::binary);
if (!in) return {};
std::ostringstream out;
out << in.rdbuf();
return out.str();
}
int ProcessId() {
#if defined(_WIN32)
return static_cast<int>(::_getpid());
#else
return static_cast<int>(::getpid());
#endif
}
// A caps snapshot the client could plausibly have received, with every field distinct from
// its default so a mirror that answered from a zeroed struct cannot look like one that
// adopted. THIS IS NOT THE STATE UNDER TEST - it is the INPUT to Adopt(), which is the
// producer; the assertions below read what the mirror hands the frontend's own accessors
// back, never what this function wrote (R-16).
struct Snapshot {
MGPCaps Caps{};
MG_Backend::FormatCapabilityCache Formats{};
RendererInfo Renderer{};
String ApiVersion;
BackendType Backend = BackendType::DirectGLES;
};
Snapshot MakeSnapshot(Uint64 consumedSubsystems, Uint64 capBits) {
Snapshot s;
s.Caps.CallMask = capBits | MGCapsConsumerBits(consumedSubsystems);
s.Caps.Dynamic.MaxComputeWorkGroupCount[0] = 65531;
s.Caps.Dynamic.MaxComputeWorkGroupCount[1] = 65532;
s.Caps.Dynamic.MaxComputeWorkGroupCount[2] = 65533;
s.Caps.Dynamic.MaxComputeWorkGroupSize[0] = 1021;
s.Caps.Dynamic.MaxComputeWorkGroupSize[1] = 1022;
s.Caps.Dynamic.MaxComputeWorkGroupSize[2] = 1023;
s.Caps.Dynamic.UniformBufferOffsetAlignment = 64;
s.Renderer.RendererName = "MobileGL Remote Test Renderer";
s.Renderer.BackendName = "Espryt";
s.Renderer.ExtraVendor = String{"c1"};
s.Renderer.RendererGLInfo.TargetGLVersion = Version{4, 6, 0, {}, {}};
s.Renderer.RendererGLInfo.TargetGLSLVersion = Version{4, 6, 0, {}, {}};
s.ApiVersion = "4.6";
return s;
}
void AdoptSnapshot(const Snapshot& s) {
CapsMirrorInstance().Adopt(s.Caps, s.Formats, s.Renderer, s.ApiVersion, s.Backend);
}
#if MGTEST_HAVE_FORK
struct ChildResult {
int Status = -1;
std::string Log;
};
template <class Body>
ChildResult RunInChild(Body body) {
ChildResult result;
// TRUNCATE, RATHER THAN REMEMBER AN OFFSET, and the difference is not cosmetic: this
// parent never logs, so MG_Util::Debug's FILE* is opened FOR THE FIRST TIME by each
// child - with "w", which truncates. An offset taken before the fork therefore points
// past the end of the child's own log, and `substr` hands back the tail of a DIFFERENT
// child's output. That is how the second death case in this file came to see the first
// one's slot name and assert on it: a control reading another control's message, which
// is one of the three shapes R-16 was written after.
{ std::ofstream truncate(g_logPath, std::ios::trunc | std::ios::binary); }
std::fflush(nullptr);
const pid_t pid = ::fork();
if (pid < 0) return result;
if (pid == 0) {
body();
::_exit(0);
}
int status = 0;
if (::waitpid(pid, &status, 0) != pid) return result;
result.Status = status;
result.Log = ReadLog();
return result;
}
bool DiedOfAbort(const ChildResult& r) {
return WIFSIGNALED(r.Status) && WTERMSIG(r.Status) == SIGABRT;
}
std::string DescribeStatus(const ChildResult& r) {
if (r.Status < 0) return "fork/waitpid failed";
if (WIFEXITED(r.Status)) return "exited " + std::to_string(WEXITSTATUS(r.Status));
if (WIFSIGNALED(r.Status)) return "signal " + std::to_string(WTERMSIG(r.Status));
return "status " + std::to_string(r.Status);
}
#endif
} // namespace
// =====================================================================================
// The emit table: the partition, and that no slot is null
// =====================================================================================
TEST(RemoteEmitTable, TheThreeClassesPartitionAllSeventyOneSlots) {
// CONTRACT-P5.md §7: 2 answered locally + 5 emitted + 64 Fatal. Read from the functions the
// table itself reports with - which is also what t1's arming condition reads - rather than
// recomputed here, so a table that lost an emitter cannot look like one that never had it.
EXPECT_EQ(LocallyAnsweredSlotCount(), 2u);
EXPECT_EQ(ImplementedVerbCount(), 5u);
EXPECT_EQ(UnmigratedSlotCount(), 64u);
EXPECT_EQ(LocallyAnsweredSlotCount() + ImplementedVerbCount() + UnmigratedSlotCount(),
kRemoteEmitSlotCount);
}
TEST(RemoteEmitTable, NoSlotIsNull) {
// R-4's whole rule, asserted over the STRUCT rather than over the list that built it. 91
// MG_Impl sites call through this table directly; a null slot is 91 potential null calls,
// and the one thing a list-driven check could not catch is a slot the list forgot to name.
//
// Walked as a block of function pointers because that is exactly what the struct is - the
// static_asserts in EmitTables.cpp pin that shape - so a slot ADDED to GLFunctionsTable is
// covered here on the day it appears, without this file being edited.
const MG_Backend::GlobalBackendFunctionsTable& table = RemoteEmitTable();
const void* const* cells = reinterpret_cast<const void* const*>(&table);
const SizeT cellCount = sizeof(table) / sizeof(void*);
// The struct is 71 function pointers plus ONE cell holding the packed Bool
// PrefersCpuXfbPrimitiveAccounting and its padding (EmitTables.cpp static_asserts exactly
// that shape). That Bool is legitimately zero when the server did not publish
// kCapCpuXfbPrimitiveAccounting, so at most one cell may read null - and this is stated as
// a bound rather than an index, because an index would drift the day a slot is inserted.
SizeT nullCells = 0;
for (SizeT i = 0; i < cellCount; ++i) {
if (cells[i] == nullptr) ++nullCells;
}
EXPECT_LE(nullCells, 1u)
<< "a slot in the remote emit table is null (" << nullCells << " null cells out of "
<< cellCount
<< "). R-4 forbids it: 91 MG_Impl sites call through this table with no null check at all";
}
TEST(RemoteEmitTable, TheFiveEmittersAreTheOnesTheCensusMeasured) {
const MG_Backend::GlobalBackendFunctionsTable& table = RemoteEmitTable();
// Named, so that a table which emitted a DIFFERENT five would be red rather than merely
// counted. The census's answer is Clear, DrawArrays, ReadPixels, BlitFramebuffer, Present.
EXPECT_NE(table.GL.Clear, nullptr);
EXPECT_NE(table.GL.DrawArrays, nullptr);
EXPECT_NE(table.GL.ReadPixels, nullptr);
EXPECT_NE(table.GL.BlitFramebuffer, nullptr);
EXPECT_NE(table.Present, nullptr);
// And the two R-15 answers them locally, so they are not the same pointer as any Fatal one.
EXPECT_NE(table.GL.GetIntegeri_v, nullptr);
EXPECT_NE(table.GL.IsTimerQuerySupported, nullptr);
EXPECT_NE(reinterpret_cast<const void*>(table.GL.DrawArrays),
reinterpret_cast<const void*>(table.GL.DrawElements))
<< "DrawArrays is class B and DrawElements is class C; they cannot share a thunk";
}
#if MGTEST_HAVE_FORK
TEST(RemoteEmitTable, AnUnmigratedSlotAbortsAndNamesItself) {
// THE DEATH TEST ON THE UnmigratedVerbFatal ARM. It asserts the exact wording, not merely
// that the child died: a control that trips on any abort is satisfied by the wrong abort,
// which is one of the three shapes R-16 was written after.
const ChildResult r = RunInChild([] {
RemoteEmitTable().GL.DrawElements(0x0004 /*GL_TRIANGLES*/, 3, 0x1405 /*GL_UNSIGNED_INT*/,
nullptr);
});
ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log;
EXPECT_NE(r.Log.find("Fatal{UnmigratedVerb, \"DrawElements\"}"), std::string::npos) << r.Log;
}
TEST(RemoteEmitTable, EachUnmigratedSlotNamesItsOwnSlot) {
// The half the case above cannot state on its own: that the name in the message is the
// slot's and not a constant. Two different slots, two different names.
const ChildResult r = RunInChild([] { RemoteEmitTable().GL.GenerateMipmap(0x0DE1); });
ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log;
EXPECT_NE(r.Log.find("Fatal{UnmigratedVerb, \"GenerateMipmap\"}"), std::string::npos) << r.Log;
EXPECT_EQ(r.Log.find("DrawElements"), std::string::npos)
<< "the Fatal message names a slot other than the one that was called:\n"
<< r.Log;
}
TEST(RemoteEmitTable, SetSwapIntervalIsClassCAndSaysSo) {
// The slot the verb census found by NOT mirroring GLImpl: SetSwapInterval has zero MG_Impl
// call sites and is reached only through the EGL path, so a table built from the 89 GLImpl
// sites would have left it null.
const ChildResult r = RunInChild([] { RemoteEmitTable().SetSwapInterval(1); });
ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log;
EXPECT_NE(r.Log.find("Fatal{UnmigratedVerb, \"SetSwapInterval\"}"), std::string::npos) << r.Log;
}
TEST(RemoteEmitTable, AClassBSlotWithNoSessionAbortsRatherThanFallingThrough) {
// The other half of "no slot may fall through to the driver". With no ClientSession the
// emitter has nowhere to put the record, and the one thing it may not do is return quietly:
// that is the split lane running monolith and going green.
const ChildResult r = RunInChild([] { RemoteEmitTable().GL.Clear(0x4000 /*COLOR_BUFFER_BIT*/); });
ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log;
EXPECT_NE(r.Log.find("Fatal{NoClientSession, \"Clear\"}"), std::string::npos) << r.Log;
}
#endif // MGTEST_HAVE_FORK
// =====================================================================================
// The caps mirror: the three read paths the acceptance names
// =====================================================================================
TEST(CapsMirrorTest, GlGetStringReadsTheRendererStringsBackOutOfTheMirror) {
// glGetString's path is GL_Getter.cpp:596 -> pActiveBackendObject->GetRendererInfo(), which
// BackendObject_Remote answers from this mirror BY REFERENCE - so the test reads the
// reference, holds it across a second adoption, and requires it to follow. A mirror that
// handed back a temporary would pass an equality check and dangle here.
const Snapshot first = MakeSnapshot(kMGPipeSubsystemResources, 0);
AdoptSnapshot(first);
const RendererInfo& bound = CapsMirrorInstance().Renderer();
EXPECT_EQ(bound.RendererName, "MobileGL Remote Test Renderer");
EXPECT_EQ(bound.BackendName, "Espryt");
ASSERT_TRUE(bound.ExtraVendor.has_value());
EXPECT_EQ(*bound.ExtraVendor, "c1");
Snapshot second = MakeSnapshot(kMGPipeSubsystemResources, 0);
second.Renderer.RendererName = "A Different Device";
AdoptSnapshot(second);
EXPECT_EQ(bound.RendererName, "A Different Device")
<< "GetRendererInfo() returns a reference, so a re-arrival must be visible through a "
"reference a caller already holds";
}
TEST(CapsMirrorTest, GlGetIntegervReadsTheDynamicParametersBackOutOfTheMirror) {
// glGetIntegerv's limit family is GL_Getter.cpp:2400 -> GetDynamicParameters(), which binds
// a reference and then reads many members - which is why a partial snapshot is not an
// option and why the whole struct crosses.
AdoptSnapshot(MakeSnapshot(kMGPipeSubsystemResources, 0));
const MG_Backend::DynamicBackendParameters& dynamic = CapsMirrorInstance().Dynamic();
EXPECT_EQ(dynamic.MaxComputeWorkGroupCount[0], 65531);
EXPECT_EQ(dynamic.MaxComputeWorkGroupCount[2], 65533);
EXPECT_EQ(dynamic.MaxComputeWorkGroupSize[1], 1022);
EXPECT_EQ(dynamic.UniformBufferOffsetAlignment, 64u);
}
TEST(CapsMirrorTest, GlGetStringiReadsTheAdvertisedExtensionListBackOutOfTheMirror) {
// glGetStringi(GL_EXTENSIONS) is GL_Getter.cpp:654 -> GetRendererInfo().RendererGLInfo, the
// same list CompileEnv.cpp:124 copies into the compile env.
Snapshot s = MakeSnapshot(kMGPipeSubsystemResources, 0);
s.Renderer.RendererGLInfo.Extensions.push_back(E_GL_ARB_timer_query);
AdoptSnapshot(s);
const auto& extensions = CapsMirrorInstance().Renderer().RendererGLInfo.Extensions;
ASSERT_EQ(extensions.size(), 1u);
EXPECT_EQ(extensions[0], E_GL_ARB_timer_query);
EXPECT_EQ(CapsMirrorInstance().Renderer().RendererGLInfo.TargetGLVersion.Major, 4);
}
TEST(CapsMirrorTest, ReArrivalIsTheInvalidationAndMovesTheGeneration) {
// R-12 has no Invalidate(), so Generation() is the ONLY thing on the client that can see a
// server context death - and a client memo has to key on it.
const Uint64 before = CapsMirrorInstance().Generation();
AdoptSnapshot(MakeSnapshot(kMGPipeSubsystemResources, 0));
const Uint64 after = CapsMirrorInstance().Generation();
EXPECT_EQ(after, before + 1);
AdoptSnapshot(MakeSnapshot(kMGPipeSubsystemResources, 0));
EXPECT_EQ(CapsMirrorInstance().Generation(), after + 1);
EXPECT_TRUE(CapsMirrorInstance().Valid());
}
TEST(CapsMirrorTest, TheBackendTypeIsTheServersAndNeverANewEnumerator) {
Snapshot s = MakeSnapshot(kMGPipeSubsystemResources, 0);
s.Backend = BackendType::DirectVulkan;
AdoptSnapshot(s);
EXPECT_EQ(CapsMirrorInstance().Backend(), BackendType::DirectVulkan);
s.Backend = BackendType::DirectGLES;
AdoptSnapshot(s);
EXPECT_EQ(CapsMirrorInstance().Backend(), BackendType::DirectGLES);
}
TEST(CapsMirrorTest, ThePrefersCpuXfbAnswerComesFromTheCapBitAndNotFromTheTable) {
// GLFunctionsTable::PrefersCpuXfbPrimitiveAccounting is a member of the FUNCTION TABLE,
// which is exactly the thing a split client never receives - so it cannot ride in
// MGPCaps::Dynamic and must come from kCapCpuXfbPrimitiveAccounting.
AdoptSnapshot(MakeSnapshot(kMGPipeSubsystemResources, 0));
EXPECT_FALSE(CapsMirrorInstance().PrefersCpuXfbPrimitiveAccounting());
AdoptSnapshot(MakeSnapshot(kMGPipeSubsystemResources, kCapCpuXfbPrimitiveAccounting));
EXPECT_TRUE(CapsMirrorInstance().PrefersCpuXfbPrimitiveAccounting());
}
// =====================================================================================
// R-8: the liveness gates read the caps mirror, and a family with no consumer says so
// =====================================================================================
TEST(CapsMirrorTest, AMaskWithoutAFamilyRefusesItAndNamesIt) {
// R-8's NEGATIVE CONTROL. "The client emits nothing for a family the server does not
// consume" is, on its own, indistinguishable from "nothing called it" - so the refusal is
// COUNTED at the one funnel that answers the question, and the count is what this asserts.
AdoptSnapshot(MakeSnapshot(kMGPipeSubsystemPrograms, 0));
ResetConsumerRefusalsForTest();
EXPECT_TRUE(CapsMirrorInstance().ServerConsumes(kMGPipeSubsystemPrograms));
EXPECT_EQ(ConsumerRefusals(), 0u) << "a family the server DOES consume must not be counted "
"as refused";
EXPECT_FALSE(CapsMirrorInstance().ServerConsumes(kMGPipeSubsystemResources));
EXPECT_EQ(ConsumerRefusals(), 1u);
EXPECT_EQ(LastRefusedSubsystem(), kMGPipeSubsystemResources)
<< "the refusal must name the family; a counter that only says 'something was withheld' "
"cannot tell five silent families apart";
EXPECT_FALSE(CapsMirrorInstance().ServerConsumes(kMGPipeSubsystemTextureResources));
EXPECT_EQ(ConsumerRefusals(), 2u);
EXPECT_EQ(LastRefusedSubsystem(), kMGPipeSubsystemTextureResources);
}
TEST(CapsMirrorTest, APlaceholderMirrorConsumesNothing) {
// The safe direction, stated as a case. With no snapshot the mask is zero, every family
// answers "no consumer", the client emits nothing and the legacy pull path runs. The unsafe
// direction - emitting to a server that has no consumer - is ID-39's 66 lost uploads.
MGPCaps empty{};
CapsMirror mirror;
EXPECT_FALSE(mirror.Valid());
EXPECT_FALSE(mirror.ServerConsumes(kMGPipeSubsystemResources));
EXPECT_FALSE(mirror.ServerConsumes(kMGPipeSubsystemPrograms));
EXPECT_FALSE(mirror.HasCap(kCapResidentSubData));
(void)empty;
}
TEST(CapsMirrorTest, TheConsumerBlockDoesNotCollideWithTheFeatureBits) {
// The two halves of CallMask, asserted against each other rather than against a constant:
// bits 0..8 are MGPCapBit and bits 32..47 are the consumer mask, and the whole reason R-8
// became implementable is that they do not overlap.
AdoptSnapshot(MakeSnapshot(kMGPipeSubsystemResources | kMGPipeSubsystemPrograms,
kCapTimerQuery | kCapOcclusionQuery));
EXPECT_TRUE(CapsMirrorInstance().HasCap(kCapTimerQuery));
EXPECT_TRUE(CapsMirrorInstance().HasCap(kCapOcclusionQuery));
EXPECT_FALSE(CapsMirrorInstance().HasCap(kCapXfbPrimitivesQuery));
EXPECT_TRUE(CapsMirrorInstance().ServerConsumes(kMGPipeSubsystemResources));
EXPECT_TRUE(CapsMirrorInstance().ServerConsumes(kMGPipeSubsystemPrograms));
EXPECT_FALSE(CapsMirrorInstance().ServerConsumes(kMGPipeSubsystemSamplers));
}
// =====================================================================================
// E2's emitter-drop control: the switch itself, driven through the real emitter's own counter
// =====================================================================================
TEST(RemoteEmitTable, TheE2DropSwitchStartsDisarmed) {
// The half a unit case can state. E2's statement - "drop one Clear emission and OpenRA's
// SSIM falls below 0.99" - is a TRACE LANE's, because the picture is the thing it is about;
// what belongs here is that the control is off unless someone armed it, so a lane that
// forgot to disarm cannot look like a lane that was never armed.
EXPECT_EQ(DroppedClearEmissions(), 0u);
SetDropClearEmissionForNegativeControl(true);
SetDropClearEmissionForNegativeControl(false);
EXPECT_EQ(DroppedClearEmissions(), 0u)
<< "arming and disarming the control must not, by itself, drop anything";
}
int main(int argc, char** argv) {
namespace fs = std::filesystem;
const fs::path path =
fs::temp_directory_path() / ("mobilegl-remoteclient-test-" + std::to_string(ProcessId()) + ".log");
std::error_code ec;
fs::remove(path, ec);
g_logPath = path.string();
#if defined(_WIN32)
_putenv_s("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str());
#else
setenv("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str(), 1);
#endif
::testing::InitGoogleTest(&argc, argv);
const int rc = RUN_ALL_TESTS();
fs::remove(path, ec);
return rc;
}