[Test] (Pipe): pin every P4a record's lifecycle, its bounds gate and what a make-current does and does not clear

This commit is contained in:
2026-09-08 15:07:23 -04:00
parent a02f1571f5
commit ae1a1c503f
7 changed files with 2134 additions and 0 deletions
@@ -32,19 +32,29 @@
#include <gtest/gtest.h>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <system_error>
#if defined(_WIN32)
#include <process.h>
#define MGTEST_HAVE_FORK 0
#else
#include <csignal>
#include <sys/wait.h>
#include <unistd.h>
#define MGTEST_HAVE_FORK 1
#endif
#include "Includes.h"
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include <MG_Impl/Pipe/SlotAllocator.h>
#include <MG_Pipe/PipeApply.h>
// create_shader_state takes the two artefact structs by pointer beside the record, so a case
// that mints a composite record needs their definitions.
#include <MG_State/GLState/ProgramState/ProgramArtifacts.h>
#endif
using namespace MobileGL;
@@ -60,6 +70,92 @@ namespace {
return static_cast<int>(getpid());
#endif
}
#if MOBILEGL_PIPE_PUSH
std::string ReadLog() {
std::ifstream in(g_logPath, std::ios::binary);
std::ostringstream ss;
ss << in.rdbuf();
return ss.str();
}
// A fresh applier per case, BOTH SCOPES, and it takes both because there are two: a reset
// is a make-current and deliberately KEEPS the object records, so a fixture that wants a
// genuinely empty applier has to say the other one as well. Every case is its own process
// under ctest, so this is belt and braces - but running the binary by hand must give the
// same answers as running it under ctest.
struct ApplierGuard {
ApplierGuard() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
~ApplierGuard() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
};
#if MGTEST_HAVE_FORK
struct ChildResult {
int Status = -1;
std::string Log;
};
template <class Body>
ChildResult RunInChild(Body body) {
ChildResult result;
std::error_code ec;
std::filesystem::remove(g_logPath, ec);
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 // MGTEST_HAVE_FORK
// Drives a call a trip wire must REFUSE, and asserts the wire NAMED what it refused. The
// two arms differ by design: a poison or verify build stops the process, so the drive is a
// forked child and the parent reads SIGABRT plus the line out of the log; a shipped push
// build logs and carries on from a defined state, so there the line is read back in process
// and the caller goes on to assert that nothing moved.
template <class Body>
void ExpectRefusedNaming(const char* needle, Body body) {
#if MOBILEGL_PIPE_POISON || MOBILEGL_PIPE_VERIFY
#if MGTEST_HAVE_FORK
const std::string tagged = std::string("Fatal{ProtocolCorruption} ") + needle;
const ChildResult child = RunInChild(body);
EXPECT_TRUE(DiedOfAbort(child)) << DescribeStatus(child) << "; log: " << child.Log;
EXPECT_NE(child.Log.find(tagged), std::string::npos)
<< "the gate fired without naming what it refused; wanted \"" << tagged << "\"; log: " << child.Log;
#else
(void)needle;
(void)body; // no fork on this platform; the verdict here is std::abort()
#endif
#else
const std::string tagged = std::string("ProtocolCorruption ") + needle;
const std::string before = ReadLog();
body();
EXPECT_NE(ReadLog().substr(before.size()).find(tagged), std::string::npos)
<< "the gate refused without saying what it refused; wanted \"" << tagged << "\"";
#endif
}
#endif // MOBILEGL_PIPE_PUSH
} // namespace
// The contract commit's one case, and it pins the property everything else in this suite is
@@ -110,6 +206,147 @@ TEST(CompositeResolver, TheCompositeBandHasExactlyOneDoor) {
#endif
}
// =========================================================================================
// The APPLIER's half of the composite band (the wire commits'). The client-side resolver - the
// signature cache keyed on ComputeDrawProgramSignature, the two release paths, the eviction -
// is the client package's and lands beside these.
//
// WHY THE APPLIER HAS A BAND AT ALL. It is not because the server knows what a composite is:
// it does not, and create / bind / delete_shader_state name one exactly as they name any other
// program. It is because the band starts at 983040, so ONE pipeline composite in a
// slot-indexed vector would grow that vector to ~983k records of ~240 bytes each - a 236 MB
// spike on the first pipeline draw. Both spaces stay dense against their own high-water mark.
// =========================================================================================
#if MOBILEGL_PIPE_PUSH
namespace {
using MG_State::GLState::LinkArtifacts;
using MG_State::GLState::SpirvArtifacts;
MGPProgramDesc CompositeDesc(MGPipeHandle cso, Uint32 stageMask) {
MGPProgramDesc desc{};
desc.Cso = cso;
desc.StageMask = stageMask;
return desc;
}
MGPHandleOnly ProgramHandle(MGPipeHandle cso) {
return MGPHandleOnly{cso, static_cast<Uint32>(MGPipeKind::ShaderCso), 0};
}
} // namespace
#endif
// The band's record lands in the band's own table and the ordinary one is not grown by it -
// which is the whole 236 MB of it - and every entry point still names it as an ordinary
// program.
TEST(CompositeResolver, ACompositeRecordLandsInTheBandsOwnTableAndNeverGrowsTheOrdinaryOne) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const LinkArtifacts link;
const SpirvArtifacts spirv;
const MGPipeHandle composite{kMGPipeShaderCsoCompositeSlotBase + 2, 1};
ASSERT_TRUE(MGPipeIsCompositeShaderSlot(composite.Slot));
MGPipeApplyCreateShaderState(CompositeDesc(composite, 0x3u), &link, &spirv);
EXPECT_TRUE(MGPipeApplier().ShaderCsos.empty())
<< "one composite grew the ordinary table to the band's base - that is the 236 MB spike";
ASSERT_EQ(MGPipeApplier().CompositeShaderCsos.size(), 3u)
<< "the band's table is indexed by (slot - base) and stays dense against its own high water";
EXPECT_TRUE(MGPipeApplier().CompositeShaderCsos[2].Live);
EXPECT_EQ(MGPipeApplier().CompositeShaderCsos[2].Gen, 1u);
EXPECT_EQ(MGPipeApplier().CompositeShaderCsos[2].Desc.StageMask, 0x3u);
// AND THE SERVER NEVER LEARNS IT IS A COMPOSITE: the ordinary bind and draw-program calls
// resolve it exactly as they resolve any other program.
MGPipeApplyBindShaderState(ProgramHandle(composite));
MGPipeApplySetDrawProgram(ProgramHandle(composite));
EXPECT_EQ(MGPipeApplier().BoundShaderCso, composite);
EXPECT_EQ(MGPipeApplier().DrawProgram, composite);
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 0u);
// An ordinary program lands in the other table, and the two do not see each other even
// though the composite's record is at index 2 of its own.
MGPipeApplyCreateShaderState(CompositeDesc(MGPipeHandle{2, 1}, 0x7u), &link, &spirv);
ASSERT_GT(MGPipeApplier().ShaderCsos.size(), 2u);
EXPECT_EQ(MGPipeApplier().ShaderCsos[2].Desc.StageMask, 0x7u);
EXPECT_EQ(MGPipeApplier().CompositeShaderCsos[2].Desc.StageMask, 0x3u)
<< "an ordinary program at slot 2 wrote the composite at band index 2";
#endif
}
// The composite's slot has TWO independent release paths - the pipeline cache's eviction and
// the composite program's own destructor - and both go through one client helper. The second
// arrival here is a refused no-op, which is what makes the double free proven rather than
// assumed, and it clears the bindings exactly once.
TEST(CompositeResolver, ASecondDeleteOfACompositeIsARefusedNoOpRatherThanASecondRelease) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const LinkArtifacts link;
const SpirvArtifacts spirv;
const MGPipeHandle composite{kMGPipeShaderCsoCompositeSlotBase, 3};
MGPipeApplyCreateShaderState(CompositeDesc(composite, 0x3u), &link, &spirv);
MGPipeApplySetDrawProgram(ProgramHandle(composite));
ASSERT_EQ(MGPipeApplier().DrawProgram, composite);
MGPipeApplyDeleteShaderState(ProgramHandle(composite));
EXPECT_FALSE(MGPipeApplier().CompositeShaderCsos[0].Live);
EXPECT_EQ(MGPipeApplier().CompositeShaderCsos[0].Gen, 3u);
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().DrawProgram));
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 0u);
const Uint64 serialAfterFirst = MGPipeApplier().ProgramBindingSerial;
MGPipeApplyDeleteShaderState(ProgramHandle(composite));
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 1u);
EXPECT_EQ(MGPipeApplier().ProgramBindingSerial, serialAfterFirst)
<< "the second release moved the binding serial, so it was not a no-op";
// And the band's slot is re-usable afterwards: a recycled composite is a new identity and
// starts its record over.
MGPipeApplyCreateShaderState(CompositeDesc(MGPipeHandle{composite.Slot, 4}, 0x1u), &link, &spirv);
EXPECT_TRUE(MGPipeApplier().CompositeShaderCsos[0].Live);
EXPECT_EQ(MGPipeApplier().CompositeShaderCsos[0].Gen, 4u);
EXPECT_EQ(MGPipeApplier().CompositeShaderCsos[0].Serial, 0u);
#endif
}
// The band is INSIDE the ShaderCso slot limit, so the bound the applier refuses at is the limit
// itself and not the band's base - a bound below it would refuse the very slots the allocator's
// one composite door is allowed to hand out.
TEST(CompositeResolver, ASlotAtTheShaderCsoLimitIsRefusedWhileTheLastBandSlotIsNot) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const LinkArtifacts link;
const SpirvArtifacts spirv;
// The positive control: the LAST slot of the band is a legal composite handle.
const MGPipeHandle last{kMGPipeShaderCsoSlotLimit - 1, 1};
ASSERT_TRUE(MGPipeIsCompositeShaderSlot(last.Slot));
MGPipeApplyCreateShaderState(CompositeDesc(last, 0x3u), &link, &spirv);
ASSERT_EQ(MGPipeApplier().CompositeShaderCsos.size(),
static_cast<SizeT>(kMGPipeShaderCsoSlotLimit - kMGPipeShaderCsoCompositeSlotBase));
EXPECT_TRUE(MGPipeApplier().CompositeShaderCsos.back().Live);
EXPECT_TRUE(MGPipeApplier().ShaderCsos.empty());
const MGPProgramDesc past = CompositeDesc(MGPipeHandle{kMGPipeShaderCsoSlotLimit, 1}, 0x3u);
ExpectRefusedNaming("create_shader_state {slot=1048576, gen=1}: the slot is outside the record table's "
"bound",
[&past, &link, &spirv]() { MGPipeApplyCreateShaderState(past, &link, &spirv); });
// And an ORDINARY slot at or above the band's base is out of range by definition: the
// allocator refuses the band for an ordinary program, so nothing legal can name one.
MGPipeApplySetDrawProgram(ProgramHandle(MGPipeHandle{kMGPipeShaderCsoCompositeSlotBase - 1, 1}));
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 1u)
<< "an ordinary slot below the band resolved against a record nobody created";
#endif
}
int main(int argc, char** argv) {
namespace fs = std::filesystem;
const fs::path path =
@@ -39,13 +39,19 @@
#include <gtest/gtest.h>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <system_error>
#if defined(_WIN32)
#include <process.h>
#define MGTEST_HAVE_FORK 0
#else
#include <csignal>
#include <sys/wait.h>
#include <unistd.h>
#define MGTEST_HAVE_FORK 1
#endif
#include "Includes.h"
@@ -68,6 +74,113 @@ namespace {
return static_cast<int>(getpid());
#endif
}
#if MOBILEGL_PIPE_PUSH
std::string ReadLog() {
std::ifstream in(g_logPath, std::ios::binary);
std::ostringstream ss;
ss << in.rdbuf();
return ss.str();
}
// A fresh applier per case, BOTH SCOPES, and it takes both because there are two: a reset
// is a make-current and deliberately KEEPS the object records, so a fixture that wants a
// genuinely empty applier has to say the other one as well. Every case is its own process
// under ctest, so this is belt and braces - but running the binary by hand must give the
// same answers as running it under ctest.
struct ApplierGuard {
ApplierGuard() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
~ApplierGuard() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
};
#if MGTEST_HAVE_FORK
struct ChildResult {
int Status = -1;
std::string Log;
};
template <class Body>
ChildResult RunInChild(Body body) {
ChildResult result;
std::error_code ec;
std::filesystem::remove(g_logPath, ec);
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 // MGTEST_HAVE_FORK
// Drives a call a trip wire must REFUSE, and asserts the wire NAMED what it refused. The
// two arms differ by design: a poison or verify build stops the process, so the drive is a
// forked child and the parent reads SIGABRT plus the line out of the log; a shipped push
// build logs and carries on from a defined state, so there the line is read back in process
// and the caller goes on to assert that nothing moved.
template <class Body>
void ExpectRefusedNaming(const char* needle, Body body) {
#if MOBILEGL_PIPE_POISON || MOBILEGL_PIPE_VERIFY
#if MGTEST_HAVE_FORK
const std::string tagged = std::string("Fatal{ProtocolCorruption} ") + needle;
const ChildResult child = RunInChild(body);
EXPECT_TRUE(DiedOfAbort(child)) << DescribeStatus(child) << "; log: " << child.Log;
EXPECT_NE(child.Log.find(tagged), std::string::npos)
<< "the gate fired without naming what it refused; wanted \"" << tagged << "\"; log: " << child.Log;
#else
(void)needle;
(void)body; // no fork on this platform; the verdict here is std::abort()
#endif
#else
const std::string tagged = std::string("ProtocolCorruption ") + needle;
const std::string before = ReadLog();
body();
EXPECT_NE(ReadLog().substr(before.size()).find(tagged), std::string::npos)
<< "the gate refused without saying what it refused; wanted \"" << tagged << "\"";
#endif
}
// A record with real values in every field a case might read back, so a body that stored
// the wrong one - or stored nothing - is visible BY FIELD.
MGPFramebufferState FramebufferRecord(MGPipeHandle fbo, MGPipeFramebufferTarget target, Uint16 width) {
MGPFramebufferState state{};
state.Fbo = fbo;
state.Target = static_cast<Uint8>(target);
state.Width = width;
state.Height = 64;
state.Layers = 1;
state.Samples = 1;
state.Complete = 1;
state.ContentHash = 0x1234u + width;
for (Uint32 i = 0; i < kMGPipeMaxColorAttachments; ++i) {
state.DrawBuffers[i] = static_cast<Int8>(i == 0 ? 0 : -1);
}
state.Color[0].Res = MGPipeHandle{9, 1};
state.Color[0].InternalFormat = 0x8058u; // GL_RGBA8
state.Color[0].Kind = 1;
return state;
}
#endif // MOBILEGL_PIPE_PUSH
} // namespace
// The one case the contract commit lands, and it is not a placeholder: it pins the SHAPE every
@@ -89,6 +202,142 @@ TEST(FramebufferEmit, TheEmitterIsOneNeverDestroyedProcessSingleton) {
#endif
}
// =========================================================================================
// The APPLIER's half of set_framebuffer_state (the wire commits'). The emitter's half - the
// resolved read surface, the draw-buffer array in the content hash, a recycled handle never
// suppressed against its predecessor, an attachment point above the wire width refused rather
// than truncated - is the client package's and lands beside these.
// =========================================================================================
// THE WHOLE POINT OF THE Target BYTE. GL has two independent framebuffer bindings and this
// record carries one Fbo and one ReadSurface, so a record says which binding it describes;
// Both is one object bound to both and writes both. Deleting either store, or the serial bump,
// leaves this red.
TEST(FramebufferEmit, ADrawRecordAndAReadRecordAreKeptApartAndBothWritesBoth) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const Uint64 serialAtStart = MGPipeApplier().FramebufferSerial;
MGPipeApplySetFramebufferState(FramebufferRecord(MGPipeHandle{4, 1}, MGPipeFramebufferTarget::Draw, 100));
EXPECT_EQ(MGPipeApplier().DrawFramebuffer.Fbo, (MGPipeHandle{4, 1}));
EXPECT_EQ(MGPipeApplier().DrawFramebuffer.Width, 100u);
EXPECT_EQ(MGPipeApplier().DrawFramebuffer.Color[0].InternalFormat, 0x8058u);
EXPECT_EQ(MGPipeApplier().DrawFramebuffer.DrawBuffers[0], 0);
EXPECT_EQ(MGPipeApplier().ReadFramebuffer.Fbo, kMGPipeNullHandle)
<< "a Draw record landed in the read binding as well";
const Uint64 afterDraw = MGPipeApplier().FramebufferSerial;
EXPECT_GT(afterDraw, serialAtStart) << "an applied record must move the serial the twin memoises";
MGPipeApplySetFramebufferState(FramebufferRecord(MGPipeHandle{5, 2}, MGPipeFramebufferTarget::Read, 200));
EXPECT_EQ(MGPipeApplier().ReadFramebuffer.Fbo, (MGPipeHandle{5, 2}));
EXPECT_EQ(MGPipeApplier().ReadFramebuffer.Width, 200u);
EXPECT_EQ(MGPipeApplier().DrawFramebuffer.Fbo, (MGPipeHandle{4, 1}))
<< "a Read record overwrote the draw binding";
EXPECT_EQ(MGPipeApplier().DrawFramebuffer.Width, 100u);
EXPECT_GT(MGPipeApplier().FramebufferSerial, afterDraw);
// Both: one record, one serial bump, two destinations.
const Uint64 beforeBoth = MGPipeApplier().FramebufferSerial;
MGPipeApplySetFramebufferState(FramebufferRecord(MGPipeHandle{6, 3}, MGPipeFramebufferTarget::Both, 300));
EXPECT_EQ(MGPipeApplier().DrawFramebuffer.Fbo, (MGPipeHandle{6, 3}));
EXPECT_EQ(MGPipeApplier().ReadFramebuffer.Fbo, (MGPipeHandle{6, 3}));
EXPECT_EQ(MGPipeApplier().DrawFramebuffer.Width, 300u);
EXPECT_EQ(MGPipeApplier().ReadFramebuffer.Width, 300u);
EXPECT_EQ(MGPipeApplier().FramebufferSerial, beforeBoth + 1)
<< "a Both record is ONE record and moves the serial once";
// A framebuffer has a handle but NO wire lifetime, so there is no record to refuse against
// and this entry point never counts an object refusal.
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 0u);
#endif
}
// A target outside the three is not a binding this server has, and guessing one would put a
// draw's attachments into the read record or the other way round.
TEST(FramebufferEmit, ATargetOutsideTheThreeBindingsIsRefusedNamingTheRecord) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
MGPFramebufferState bad = FramebufferRecord(MGPipeHandle{7, 4}, MGPipeFramebufferTarget::Draw, 100);
bad.Target = static_cast<Uint8>(MGPipeFramebufferTarget::Count);
const Uint64 serialBefore = MGPipeApplier().FramebufferSerial;
ExpectRefusedNaming("set_framebuffer_state {slot=7, gen=4, target=3}: the record names no framebuffer "
"binding target",
[&bad]() { MGPipeApplySetFramebufferState(bad); });
EXPECT_EQ(MGPipeApplier().DrawFramebuffer.Fbo, kMGPipeNullHandle);
EXPECT_EQ(MGPipeApplier().ReadFramebuffer.Fbo, kMGPipeNullHandle);
EXPECT_EQ(MGPipeApplier().FramebufferSerial, serialBefore)
<< "a refused record must not move the serial";
#endif
}
// The draw-buffer array is an INDEX into this record's own Color[], and -1 is NONE. An entry
// outside that range would have the server read a colour attachment the record does not carry,
// which is the truncation the wire width's cap refusal exists to prevent upstream.
TEST(FramebufferEmit, ADrawBufferEntryOutsideTheRecordsOwnArrayIsRefusedRatherThanRead) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
// The positive control first: -1 everywhere and the last legal index are both fine, so
// what follows is refusing the value and not the loop around it.
MGPFramebufferState legal = FramebufferRecord(MGPipeHandle{8, 1}, MGPipeFramebufferTarget::Draw, 100);
legal.DrawBuffers[7] = static_cast<Int8>(kMGPipeMaxColorAttachments - 1);
MGPipeApplySetFramebufferState(legal);
ASSERT_EQ(MGPipeApplier().DrawFramebuffer.Fbo, (MGPipeHandle{8, 1}));
const Uint64 serialBefore = MGPipeApplier().FramebufferSerial;
MGPFramebufferState past = FramebufferRecord(MGPipeHandle{8, 1}, MGPipeFramebufferTarget::Draw, 111);
past.DrawBuffers[3] = static_cast<Int8>(kMGPipeMaxColorAttachments);
ExpectRefusedNaming("set_framebuffer_state {slot=8, gen=1, target=0}: a draw-buffer entry names a "
"colour attachment outside the record's own array",
[&past]() { MGPipeApplySetFramebufferState(past); });
MGPFramebufferState negative = FramebufferRecord(MGPipeHandle{8, 1}, MGPipeFramebufferTarget::Draw, 222);
negative.DrawBuffers[0] = -2;
ExpectRefusedNaming("set_framebuffer_state {slot=8, gen=1, target=0}: a draw-buffer entry names a "
"colour attachment outside the record's own array",
[&negative]() { MGPipeApplySetFramebufferState(negative); });
EXPECT_EQ(MGPipeApplier().DrawFramebuffer.Width, 100u) << "a refused record was stored anyway";
EXPECT_EQ(MGPipeApplier().FramebufferSerial, serialBefore);
#endif
}
// D-J4. The two framebuffer records are per-context WORKING state and a make-current takes
// them - but their serial ADVANCES rather than restarting, because a counter that walks back
// through values it has already stamped into a twin that outlived the switch is not a
// generation at all. Restoring `= 0` anywhere in the reset leaves this red.
TEST(FramebufferEmit, AMakeCurrentClearsBothRecordsAndAdvancesTheSerialRatherThanZeroingIt) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
MGPipeApplySetFramebufferState(FramebufferRecord(MGPipeHandle{4, 1}, MGPipeFramebufferTarget::Both, 100));
const Uint64 serialBefore = MGPipeApplier().FramebufferSerial;
ASSERT_EQ(MGPipeApplier().DrawFramebuffer.Width, 100u);
MGPipeApplierReset(); // the make-current
EXPECT_EQ(MGPipeApplier().DrawFramebuffer.Fbo, kMGPipeNullHandle);
EXPECT_EQ(MGPipeApplier().ReadFramebuffer.Fbo, kMGPipeNullHandle);
EXPECT_EQ(MGPipeApplier().DrawFramebuffer.Width, 0u);
EXPECT_GT(MGPipeApplier().FramebufferSerial, serialBefore)
<< "the serial was carried over or restarted; the cleared window is itself a change the "
"twin has to hear about, and no stamped value may ever recur";
// And the teardown scope advances it again, for the same reason.
const Uint64 afterReset = MGPipeApplier().FramebufferSerial;
MGPipeApplierReleaseObjectRecords();
EXPECT_GT(MGPipeApplier().FramebufferSerial, afterReset);
#endif
}
int main(int argc, char** argv) {
// Before anything logs: the logger reads this variable once, on its first write, and
// caches the handle. The name carries this process's pid, and the file is removed on the
+248
View File
@@ -30,13 +30,19 @@
#include <gtest/gtest.h>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <system_error>
#if defined(_WIN32)
#include <process.h>
#define MGTEST_HAVE_FORK 0
#else
#include <csignal>
#include <sys/wait.h>
#include <unistd.h>
#define MGTEST_HAVE_FORK 1
#endif
#include "Includes.h"
@@ -59,6 +65,92 @@ namespace {
return static_cast<int>(getpid());
#endif
}
#if MOBILEGL_PIPE_PUSH
std::string ReadLog() {
std::ifstream in(g_logPath, std::ios::binary);
std::ostringstream ss;
ss << in.rdbuf();
return ss.str();
}
// A fresh applier per case, BOTH SCOPES, and it takes both because there are two: a reset
// is a make-current and deliberately KEEPS the object records, so a fixture that wants a
// genuinely empty applier has to say the other one as well. Every case is its own process
// under ctest, so this is belt and braces - but running the binary by hand must give the
// same answers as running it under ctest.
struct ApplierGuard {
ApplierGuard() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
~ApplierGuard() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
};
#if MGTEST_HAVE_FORK
struct ChildResult {
int Status = -1;
std::string Log;
};
template <class Body>
ChildResult RunInChild(Body body) {
ChildResult result;
std::error_code ec;
std::filesystem::remove(g_logPath, ec);
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 // MGTEST_HAVE_FORK
// Drives a call a trip wire must REFUSE, and asserts the wire NAMED what it refused. The
// two arms differ by design: a poison or verify build stops the process, so the drive is a
// forked child and the parent reads SIGABRT plus the line out of the log; a shipped push
// build logs and carries on from a defined state, so there the line is read back in process
// and the caller goes on to assert that nothing moved.
template <class Body>
void ExpectRefusedNaming(const char* needle, Body body) {
#if MOBILEGL_PIPE_POISON || MOBILEGL_PIPE_VERIFY
#if MGTEST_HAVE_FORK
const std::string tagged = std::string("Fatal{ProtocolCorruption} ") + needle;
const ChildResult child = RunInChild(body);
EXPECT_TRUE(DiedOfAbort(child)) << DescribeStatus(child) << "; log: " << child.Log;
EXPECT_NE(child.Log.find(tagged), std::string::npos)
<< "the gate fired without naming what it refused; wanted \"" << tagged << "\"; log: " << child.Log;
#else
(void)needle;
(void)body; // no fork on this platform; the verdict here is std::abort()
#endif
#else
const std::string tagged = std::string("ProtocolCorruption ") + needle;
const std::string before = ReadLog();
body();
EXPECT_NE(ReadLog().substr(before.size()).find(tagged), std::string::npos)
<< "the gate refused without saying what it refused; wanted \"" << tagged << "\"";
#endif
}
#endif // MOBILEGL_PIPE_PUSH
} // namespace
// See FramebufferEmitTest's twin for why this is a shape pin rather than a placeholder.
@@ -75,6 +167,162 @@ TEST(ImageEmit, TheEmitterIsOneNeverDestroyedProcessSingleton) {
#endif
}
// =========================================================================================
// The APPLIER's half of set_shader_images (the wire commits'). The emitter's half - the
// high-water-zero early-out, the content hash covering Access and InternalFormat, the shutter
// keyed on the FRONTEND sampling-resolution generation - is the client package's.
// =========================================================================================
#if MOBILEGL_PIPE_PUSH
namespace {
// Every field carries a value of its own, and two of them are the point: InternalFormat and
// Access are live glBindImageTexture state that the format-less image bake keys on, so a
// body that dropped either would leave the server baking against a format the shader was
// not built for.
MGPImageView ImageAt(Uint32 unit, Uint32 internalFormat, Uint8 access) {
MGPImageView view{};
view.Res = MGPipeHandle{unit + 1, 1};
view.Unit = unit;
view.InternalFormat = internalFormat;
view.Layer = 3;
view.Level = 2;
view.Layered = 1;
view.Access = access;
return view;
}
} // namespace
#endif
// The window rule, one field at a time: the entries land where the header says and nowhere
// else, and every field of an entry survives. Deleting the copy loop, the two window
// assignments or the serial bump leaves this red.
TEST(ImageEmit, TheImageSetLandsInItsWindowWithEveryFieldTheShaderWasBuiltAgainst) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
// Access is a Uint8 on the wire - the client's own read/write/read-write encoding, not a
// GL enum - and InternalFormat is the application's, which the server recasts.
const MGPImageView entries[2] = {ImageAt(2, 0x8814u /* GL_RGBA32F */, 2 /* write only */),
ImageAt(3, 0x8230u /* GL_RG32F */, 3 /* read write */)};
MGPShaderImages header{};
header.Start = 2;
header.Count = 2;
header.ContentHash = 0x5150u;
const Uint64 serialBefore = MGPipeApplier().ShaderImagesSerial;
// The other two sets' serials, taken AFTER the fixture: a reset and a teardown each advance
// every working serial, so "unchanged" is measured from here rather than from zero.
const Uint64 samplerViewsSerial = MGPipeApplier().SamplerViewsSerial;
MGPipeApplySetShaderImages(header, entries);
EXPECT_EQ(MGPipeApplier().ShaderImageStart, 2u);
EXPECT_EQ(MGPipeApplier().ShaderImageCount, 2u);
EXPECT_EQ(MGPipeApplier().BoundShaderImages[2].Res, (MGPipeHandle{3, 1}));
EXPECT_EQ(MGPipeApplier().BoundShaderImages[2].InternalFormat, 0x8814u);
EXPECT_EQ(MGPipeApplier().BoundShaderImages[2].Access, 2u);
EXPECT_EQ(MGPipeApplier().BoundShaderImages[3].Access, 3u);
EXPECT_EQ(MGPipeApplier().BoundShaderImages[2].Level, 2u);
EXPECT_EQ(MGPipeApplier().BoundShaderImages[2].Layer, 3u);
EXPECT_EQ(MGPipeApplier().BoundShaderImages[2].Layered, 1u);
EXPECT_EQ(MGPipeApplier().BoundShaderImages[3].InternalFormat, 0x8230u);
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundShaderImages[1].Res)) << "the set wrote below its window";
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundShaderImages[4].Res)) << "the set wrote above its window";
EXPECT_GT(MGPipeApplier().ShaderImagesSerial, serialBefore);
// "The last set as received": a narrower set says nothing about what it does not name.
MGPShaderImages narrow{};
narrow.Start = 2;
narrow.Count = 1;
MGPipeApplySetShaderImages(narrow, entries);
EXPECT_EQ(MGPipeApplier().ShaderImageCount, 1u);
EXPECT_EQ(MGPipeApplier().BoundShaderImages[3].InternalFormat, 0x8230u)
<< "the entry outside the new window was cleared";
EXPECT_EQ(MGPipeApplier().SamplerViewsSerial, samplerViewsSerial)
<< "the image set moved another set's serial; the three are independent";
#endif
}
// The window gate, at the bound and one past it, and the null-tail arm. The image-unit space
// is the same merged 192 the sampler units are.
TEST(ImageEmit, AnImageWindowPastTheImageUnitSpaceIsRefusedRatherThanTruncated) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPImageView entry = ImageAt(0, 0x8058u, 2);
MGPShaderImages exact{};
exact.Start = kMGPipeMaxImageUnits - 1;
exact.Count = 1;
MGPipeApplySetShaderImages(exact, &entry);
ASSERT_EQ(MGPipeApplier().ShaderImageCount, 1u);
const Uint64 serialBefore = MGPipeApplier().ShaderImagesSerial;
MGPShaderImages past{};
past.Start = kMGPipeMaxImageUnits;
past.Count = 1;
past.ContentHash = 9;
ExpectRefusedNaming("set_shader_images {start=192, count=1, hash=9}: the window runs past the merged "
"texture-unit space",
[&past, &entry]() { MGPipeApplySetShaderImages(past, &entry); });
MGPShaderImages noTail{};
noTail.Start = 0;
noTail.Count = 1;
ExpectRefusedNaming("set_shader_images {start=0, count=1, hash=0}: a non-empty set carries no entries",
[&noTail]() { MGPipeApplySetShaderImages(noTail, nullptr); });
EXPECT_EQ(MGPipeApplier().ShaderImagesSerial, serialBefore) << "a refused set moved the serial";
EXPECT_EQ(MGPipeApplier().ShaderImageStart, kMGPipeMaxImageUnits - 1);
#endif
}
// An EMPTY set is not a refusal: it is what a program with no image uniforms publishes, and it
// still moves the serial, because "no images" is a state the twin has to hear about.
TEST(ImageEmit, AnEmptySetIsAppliedRatherThanRefusedAndStillMovesTheSerial) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPImageView entry = ImageAt(0, 0x8058u, 1);
MGPShaderImages filled{};
filled.Count = 1;
MGPipeApplySetShaderImages(filled, &entry);
const Uint64 serialBefore = MGPipeApplier().ShaderImagesSerial;
MGPShaderImages empty{};
MGPipeApplySetShaderImages(empty, nullptr);
EXPECT_EQ(MGPipeApplier().ShaderImageCount, 0u);
EXPECT_GT(MGPipeApplier().ShaderImagesSerial, serialBefore);
EXPECT_EQ(MGPipeApplier().BoundShaderImages[0].InternalFormat, 0x8058u)
<< "an empty window cleared entries it never named";
#endif
}
// D-J4: the image set is per-context WORKING state, so a make-current takes it and ADVANCES
// its serial rather than restarting it.
TEST(ImageEmit, AMakeCurrentClearsTheImageSetAndAdvancesItsSerial) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPImageView entry = ImageAt(1, 0x8058u, 1);
MGPShaderImages header{};
header.Start = 1;
header.Count = 1;
MGPipeApplySetShaderImages(header, &entry);
const Uint64 serialBefore = MGPipeApplier().ShaderImagesSerial;
MGPipeApplierReset();
EXPECT_EQ(MGPipeApplier().ShaderImageCount, 0u);
EXPECT_EQ(MGPipeApplier().ShaderImageStart, 0u);
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundShaderImages[1].Res));
EXPECT_GT(MGPipeApplier().ShaderImagesSerial, serialBefore);
#endif
}
int main(int argc, char** argv) {
namespace fs = std::filesystem;
const fs::path path =
+397
View File
@@ -28,13 +28,19 @@
#include <gtest/gtest.h>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <system_error>
#if defined(_WIN32)
#include <process.h>
#define MGTEST_HAVE_FORK 0
#else
#include <csignal>
#include <sys/wait.h>
#include <unistd.h>
#define MGTEST_HAVE_FORK 1
#endif
#include "Includes.h"
@@ -42,6 +48,10 @@
#if MOBILEGL_PIPE_PUSH
#include <MG_Impl/Pipe/ProgramEmit.h>
#include <MG_Pipe/PipeApply.h>
// The applier takes the two artefact structs BY POINTER beside the record, so a case that
// drives create_shader_state needs their definitions - the applier's own header deliberately
// only forward-declares them.
#include <MG_State/GLState/ProgramState/ProgramArtifacts.h>
#endif
using namespace MobileGL;
@@ -57,6 +67,92 @@ namespace {
return static_cast<int>(getpid());
#endif
}
#if MOBILEGL_PIPE_PUSH
std::string ReadLog() {
std::ifstream in(g_logPath, std::ios::binary);
std::ostringstream ss;
ss << in.rdbuf();
return ss.str();
}
// A fresh applier per case, BOTH SCOPES, and it takes both because there are two: a reset
// is a make-current and deliberately KEEPS the object records, so a fixture that wants a
// genuinely empty applier has to say the other one as well. Every case is its own process
// under ctest, so this is belt and braces - but running the binary by hand must give the
// same answers as running it under ctest.
struct ApplierGuard {
ApplierGuard() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
~ApplierGuard() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
};
#if MGTEST_HAVE_FORK
struct ChildResult {
int Status = -1;
std::string Log;
};
template <class Body>
ChildResult RunInChild(Body body) {
ChildResult result;
std::error_code ec;
std::filesystem::remove(g_logPath, ec);
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 // MGTEST_HAVE_FORK
// Drives a call a trip wire must REFUSE, and asserts the wire NAMED what it refused. The
// two arms differ by design: a poison or verify build stops the process, so the drive is a
// forked child and the parent reads SIGABRT plus the line out of the log; a shipped push
// build logs and carries on from a defined state, so there the line is read back in process
// and the caller goes on to assert that nothing moved.
template <class Body>
void ExpectRefusedNaming(const char* needle, Body body) {
#if MOBILEGL_PIPE_POISON || MOBILEGL_PIPE_VERIFY
#if MGTEST_HAVE_FORK
const std::string tagged = std::string("Fatal{ProtocolCorruption} ") + needle;
const ChildResult child = RunInChild(body);
EXPECT_TRUE(DiedOfAbort(child)) << DescribeStatus(child) << "; log: " << child.Log;
EXPECT_NE(child.Log.find(tagged), std::string::npos)
<< "the gate fired without naming what it refused; wanted \"" << tagged << "\"; log: " << child.Log;
#else
(void)needle;
(void)body; // no fork on this platform; the verdict here is std::abort()
#endif
#else
const std::string tagged = std::string("ProtocolCorruption ") + needle;
const std::string before = ReadLog();
body();
EXPECT_NE(ReadLog().substr(before.size()).find(tagged), std::string::npos)
<< "the gate refused without saying what it refused; wanted \"" << tagged << "\"";
#endif
}
#endif // MOBILEGL_PIPE_PUSH
} // namespace
// See FramebufferEmitTest's twin for why this is a shape pin rather than a placeholder.
@@ -74,6 +170,307 @@ TEST(ProgramEmit, TheEmitterIsOneNeverDestroyedProcessSingleton) {
#endif
}
// =========================================================================================
// The APPLIER's half of the program family (the wire commits'): the shader CSO record, the
// three bindings and the default uniform block. The emitter's half - the join at the validate
// point, the never-uploaded sentinel that must never be emitted, the composite resolver - is
// the client package's and lands beside these.
// =========================================================================================
#if MOBILEGL_PIPE_PUSH
namespace {
using MG_State::GLState::LinkArtifacts;
using MG_State::GLState::SpirvArtifacts;
MGPProgramDesc ProgramDesc(MGPipeHandle cso, Uint32 stageMask, Uint32 globalUboSize) {
MGPProgramDesc desc{};
desc.Cso = cso;
desc.StageMask = stageMask;
desc.GlobalUboSize = globalUboSize;
desc.ReservedNumSamplesOffset = 32;
desc.SpirvStatus = 1;
desc.NativeFloat64 = 1;
desc.PointSizeDemoted = 1;
desc.EnableSpirvValidation = 1;
// ALL SEVEN BLOB REFS ARE DECLARED WITH Size 0 - "this record does not declare its
// blob" - which is exactly what a monolith emission is: the artefacts ride beside the
// record through the two companion pointers and the codec is never called.
return desc;
}
MGPHandleOnly ProgramHandle(MGPipeHandle cso) {
return MGPHandleOnly{cso, static_cast<Uint32>(MGPipeKind::ShaderCso), 0};
}
MGPGlobalConstants GlobalConstants(MGPipeHandle cso, Uint32 version) {
MGPGlobalConstants record{};
record.ShaderCso = cso;
record.Version = version;
return record;
}
const MGPipeShaderCsoRecord& ProgramRecordOf(Uint32 slot) {
EXPECT_GT(MGPipeApplier().ShaderCsos.size(), static_cast<SizeT>(slot));
return MGPipeApplier().ShaderCsos[slot];
}
} // namespace
#endif
// A create starts the record over and leaves Serial at 0; a RE-ISSUE on the same handle is how
// a relink travels, and it takes the default uniform block with it - a block sized to a layout
// that no longer exists is worse than no block, and the sentinel is the value that says
// "nothing has been uploaded for this program".
TEST(ProgramEmit, ACreateStoresTheDescriptorAndARelinkCountsUpAndDropsTheBlockKeyedToTheOldLayout) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const LinkArtifacts link;
const SpirvArtifacts spirv;
const MGPipeHandle cso{5, 2};
const Uint8 block[64] = {};
MGPipeApplyCreateShaderState(ProgramDesc(cso, 0x3u, 64), &link, &spirv);
EXPECT_TRUE(ProgramRecordOf(5).Live);
EXPECT_EQ(ProgramRecordOf(5).Gen, 2u);
EXPECT_EQ(ProgramRecordOf(5).Serial, 0u) << "a create is not a mutation";
EXPECT_EQ(ProgramRecordOf(5).Desc.StageMask, 0x3u);
EXPECT_EQ(ProgramRecordOf(5).Desc.GlobalUboSize, 64u);
EXPECT_EQ(ProgramRecordOf(5).Desc.ReservedNumSamplesOffset, 32u);
EXPECT_EQ(ProgramRecordOf(5).Desc.NativeFloat64, 1u);
EXPECT_EQ(ProgramRecordOf(5).GlobalConstantsVersion, ~Uint32{0})
<< "a fresh record starts at the never-uploaded sentinel";
MGPipeApplySetGlobalConstants(GlobalConstants(cso, 7), block);
ASSERT_EQ(ProgramRecordOf(5).GlobalConstants.size(), 64u);
const Uint64 blockSerial = ProgramRecordOf(5).GlobalConstantsSerial;
// The relink.
MGPipeApplyCreateShaderState(ProgramDesc(cso, 0x7u, 32), &link, &spirv);
EXPECT_EQ(ProgramRecordOf(5).Serial, 1u);
EXPECT_EQ(ProgramRecordOf(5).Desc.StageMask, 0x7u);
EXPECT_TRUE(ProgramRecordOf(5).GlobalConstants.empty())
<< "a block sized to the layout the relink replaced survived it";
EXPECT_EQ(ProgramRecordOf(5).GlobalConstantsVersion, ~Uint32{0});
EXPECT_GT(ProgramRecordOf(5).GlobalConstantsSerial, blockSerial)
<< "the clearing was not announced, so a twin can still match what it uploaded before";
// A RECYCLED SLOT STARTS OVER: inheriting one field of the previous occupant is how a
// program at a recycled slot inherits its predecessor's reflection.
MGPipeApplyCreateShaderState(ProgramDesc(MGPipeHandle{5, 3}, 0x1u, 16), &link, &spirv);
EXPECT_EQ(ProgramRecordOf(5).Gen, 3u);
EXPECT_EQ(ProgramRecordOf(5).Serial, 0u) << "a recycled slot kept its predecessor's serial";
EXPECT_EQ(ProgramRecordOf(5).Desc.StageMask, 0x1u);
#endif
}
// The three refusals a create can produce: no artefacts at all behind seven undeclared blobs, a
// default uniform block no program can have, and a slot outside the record table's bound.
TEST(ProgramEmit, ACreateWithNoArtefactsAnOversizedBlockOrACorruptSlotIsRefusedNamingTheProgram) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const LinkArtifacts link;
const SpirvArtifacts spirv;
const MGPProgramDesc desc = ProgramDesc(MGPipeHandle{4, 1}, 0x3u, 0);
ExpectRefusedNaming("create_shader_state {slot=4, gen=1}: the record declares no blobs and carries no "
"artefacts",
[&desc, &spirv]() { MGPipeApplyCreateShaderState(desc, nullptr, &spirv); });
ExpectRefusedNaming("create_shader_state {slot=4, gen=1}: the record declares no blobs and carries no "
"artefacts",
[&desc, &link]() { MGPipeApplyCreateShaderState(desc, &link, nullptr); });
EXPECT_TRUE(MGPipeApplier().ShaderCsos.empty());
const MGPProgramDesc huge = ProgramDesc(MGPipeHandle{4, 1}, 0x3u, kMGPipeMaxGlobalConstantsBytes + 1);
ExpectRefusedNaming("create_shader_state {slot=4, gen=1}: the default uniform block is larger than any "
"program may declare",
[&huge, &link, &spirv]() { MGPipeApplyCreateShaderState(huge, &link, &spirv); });
EXPECT_TRUE(MGPipeApplier().ShaderCsos.empty()) << "the table was grown by a refused record";
const MGPProgramDesc pastTheBound = ProgramDesc(MGPipeHandle{kMGPipeMaxShaderCsoSlots, 1}, 0x3u, 0);
ExpectRefusedNaming("create_shader_state {slot=1048576, gen=1}: the slot is outside the record table's "
"bound",
[&pastTheBound, &link, &spirv]() {
MGPipeApplyCreateShaderState(pastTheBound, &link, &spirv);
});
EXPECT_TRUE(MGPipeApplier().ShaderCsos.empty());
EXPECT_TRUE(MGPipeApplier().CompositeShaderCsos.empty());
#endif
}
// Three bindings, one serial, and each of them follows its OWN handle: set_draw_program and
// set_dispatch_program are two calls because the frontend has two joins. A null handle is legal
// and means "nothing bound"; a dead one leaves the previous binding standing and is counted.
TEST(ProgramEmit, TheThreeBindingsFollowTheirOwnHandleAndADeadOneLeavesThePreviousBindingStanding) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const LinkArtifacts link;
const SpirvArtifacts spirv;
const MGPipeHandle draw{2, 1};
const MGPipeHandle dispatch{3, 1};
MGPipeApplyCreateShaderState(ProgramDesc(draw, 0x3u, 0), &link, &spirv);
MGPipeApplyCreateShaderState(ProgramDesc(dispatch, 0x20u, 0), &link, &spirv);
const Uint64 serialBefore = MGPipeApplier().ProgramBindingSerial;
MGPipeApplyBindShaderState(ProgramHandle(draw));
MGPipeApplySetDrawProgram(ProgramHandle(draw));
MGPipeApplySetDispatchProgram(ProgramHandle(dispatch));
EXPECT_EQ(MGPipeApplier().BoundShaderCso, draw);
EXPECT_EQ(MGPipeApplier().DrawProgram, draw);
EXPECT_EQ(MGPipeApplier().DispatchProgram, dispatch);
EXPECT_EQ(MGPipeApplier().ProgramBindingSerial, serialBefore + 3);
// A dead handle: previous binding untouched, and COUNTED - a no-op nobody can see is a
// dropped bind nobody can see.
const Uint64 refusedBefore = MGPipeApplier().RefusedObjectCalls;
MGPipeApplySetDrawProgram(ProgramHandle(MGPipeHandle{2, 9}));
MGPipeApplyBindShaderState(ProgramHandle(MGPipeHandle{99, 1}));
MGPipeApplySetDispatchProgram(ProgramHandle(MGPipeHandle{3, 9}));
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, refusedBefore + 3);
EXPECT_EQ(MGPipeApplier().DrawProgram, draw);
EXPECT_EQ(MGPipeApplier().BoundShaderCso, draw);
EXPECT_EQ(MGPipeApplier().DispatchProgram, dispatch);
EXPECT_EQ(MGPipeApplier().ProgramBindingSerial, serialBefore + 3) << "a refused bind moved the serial";
// The null handle is a state, not an error.
MGPipeApplySetDrawProgram(ProgramHandle(kMGPipeNullHandle));
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().DrawProgram));
EXPECT_EQ(MGPipeApplier().ProgramBindingSerial, serialBefore + 4);
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, refusedBefore + 3) << "a null bind was counted as a refusal";
#endif
}
// A delete drops the record whole, keeps the generation, and clears EVERY binding that named
// it - unlike the unit sets, which are "the last set as received". A binding left pointing at a
// dropped record would make the next verb refuse a state the applier itself created.
TEST(ProgramEmit, ADeleteDropsTheRecordAndClearsEveryBindingThatNamedIt) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const LinkArtifacts link;
const SpirvArtifacts spirv;
const MGPipeHandle cso{6, 4};
MGPipeApplyCreateShaderState(ProgramDesc(cso, 0x3u, 0), &link, &spirv);
MGPipeApplyBindShaderState(ProgramHandle(cso));
MGPipeApplySetDrawProgram(ProgramHandle(cso));
MGPipeApplySetDispatchProgram(ProgramHandle(cso));
const Uint64 serialBefore = MGPipeApplier().ProgramBindingSerial;
MGPipeApplyDeleteShaderState(ProgramHandle(cso));
EXPECT_FALSE(ProgramRecordOf(6).Live);
EXPECT_EQ(ProgramRecordOf(6).Gen, 4u) << "a destroy keeps the generation";
EXPECT_EQ(ProgramRecordOf(6).Desc.StageMask, 0u)
<< "a stale read of a deleted slot must find nothing, not the program that used to be there";
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundShaderCso));
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().DrawProgram));
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().DispatchProgram));
EXPECT_GT(MGPipeApplier().ProgramBindingSerial, serialBefore);
// THE SECOND NOTICE IS A REFUSED NO-OP. A composite's slot has two independent release
// paths and both arrive here; the second finding nothing is what makes the double free
// proven rather than assumed.
const Uint64 serialAfter = MGPipeApplier().ProgramBindingSerial;
MGPipeApplyDeleteShaderState(ProgramHandle(cso));
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 1u);
EXPECT_EQ(MGPipeApplier().ProgramBindingSerial, serialAfter);
#endif
}
// The default uniform block lands on the PROGRAM's record - it is (ShaderCso, Version) keyed
// and belongs to the program, not to the context that uploaded it - and the length it is held
// to is the program's own GlobalUboSize, which the create already bounded.
TEST(ProgramEmit, TheDefaultUniformBlockLandsOnTheProgramsRecordAndTheSentinelIsRefused) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const LinkArtifacts link;
const SpirvArtifacts spirv;
const MGPipeHandle cso{7, 1};
MGPipeApplyCreateShaderState(ProgramDesc(cso, 0x3u, 8), &link, &spirv);
Uint8 block[8] = {1, 2, 3, 4, 5, 6, 7, 8};
MGPipeApplySetGlobalConstants(GlobalConstants(cso, 11), block);
ASSERT_EQ(ProgramRecordOf(7).GlobalConstants.size(), 8u);
EXPECT_EQ(ProgramRecordOf(7).GlobalConstants[7], 8u);
EXPECT_EQ(ProgramRecordOf(7).GlobalConstantsVersion, 11u);
EXPECT_EQ(ProgramRecordOf(7).GlobalConstantsSerial, 1u);
EXPECT_EQ(ProgramRecordOf(7).Serial, 0u) << "a block upload is not a relink";
// A DECLARED blob length that agrees is fine; one that does not is refused, and so is the
// sentinel the backends read as "never uploaded".
MGPGlobalConstants declared = GlobalConstants(cso, 12);
declared.Blob.Size = 8;
MGPipeApplySetGlobalConstants(declared, block);
EXPECT_EQ(ProgramRecordOf(7).GlobalConstantsVersion, 12u);
MGPGlobalConstants lying = GlobalConstants(cso, 13);
lying.Blob.Size = 9;
ExpectRefusedNaming("set_global_constants {slot=7, gen=1}: the declared blob length is not the "
"program's own default uniform block size",
[&lying, &block]() { MGPipeApplySetGlobalConstants(lying, block); });
const MGPGlobalConstants sentinel = GlobalConstants(cso, ~Uint32{0});
ExpectRefusedNaming("set_global_constants {slot=7, gen=1}: the version is the backends' "
"never-uploaded sentinel",
[&sentinel, &block]() { MGPipeApplySetGlobalConstants(sentinel, block); });
const MGPGlobalConstants noBytes = GlobalConstants(cso, 14);
ExpectRefusedNaming("set_global_constants {slot=7, gen=1}: a non-empty block carries no bytes",
[&noBytes]() { MGPipeApplySetGlobalConstants(noBytes, nullptr); });
EXPECT_EQ(ProgramRecordOf(7).GlobalConstantsVersion, 12u) << "a refused block was stored anyway";
EXPECT_EQ(ProgramRecordOf(7).GlobalConstantsSerial, 2u);
// And a block for a program this applier does not have is the ordinary counted refusal.
MGPipeApplySetGlobalConstants(GlobalConstants(MGPipeHandle{7, 2}, 15), block);
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 1u);
#endif
}
// D-J4 for this family: the program record is share-group state and survives a make-current -
// re-emitting create_shader_state for a record the applier still holds would move its serial
// for nothing - while the three bindings are working state and do not.
TEST(ProgramEmit, TheProgramRecordSurvivesAMakeCurrentWhileTheThreeBindingsDoNot) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const LinkArtifacts link;
const SpirvArtifacts spirv;
const MGPipeHandle cso{8, 1};
const Uint8 block[4] = {9, 9, 9, 9};
MGPipeApplyCreateShaderState(ProgramDesc(cso, 0x3u, 4), &link, &spirv);
MGPipeApplySetGlobalConstants(GlobalConstants(cso, 21), block);
MGPipeApplyBindShaderState(ProgramHandle(cso));
MGPipeApplySetDrawProgram(ProgramHandle(cso));
const Uint64 bindingSerial = MGPipeApplier().ProgramBindingSerial;
MGPipeApplierReset();
ASSERT_TRUE(ProgramRecordOf(8).Live) << "a make-current dropped a share-group program record";
EXPECT_EQ(ProgramRecordOf(8).GlobalConstantsVersion, 21u);
EXPECT_EQ(ProgramRecordOf(8).GlobalConstants.size(), 4u);
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundShaderCso));
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().DrawProgram));
EXPECT_GT(MGPipeApplier().ProgramBindingSerial, bindingSerial)
<< "the binding serial was carried over or restarted rather than advanced";
// The bind that follows the switch still resolves, which is the whole point of the rule.
MGPipeApplySetDrawProgram(ProgramHandle(cso));
EXPECT_EQ(MGPipeApplier().DrawProgram, cso);
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 0u);
MGPipeApplierReleaseObjectRecords();
EXPECT_TRUE(MGPipeApplier().ShaderCsos.empty());
#endif
}
int main(int argc, char** argv) {
namespace fs = std::filesystem;
const fs::path path =
+148
View File
@@ -1323,6 +1323,154 @@ namespace {
#endif
}
// =====================================================================================
// P4a: the four resource entry points now BRANCH ON THE DESCRIPTOR'S TARGET.
//
// The slot spaces of kinds Buffer, Texture and Renderbuffer are independent - the client
// allocator is per kind - so one slot-indexed table would alias three live objects onto one
// record. These cases are about the branch and nothing else: which table a call lands in,
// that the three do not see each other, and that a target or a kind the catalogue does not
// name is refused rather than routed to whichever table came first. The texture family's
// own behaviour (parameters, the sub-data validator, the pending-upload set) is in
// TextureEmitTest beside the emitter cases it belongs with.
// =====================================================================================
#if MOBILEGL_PIPE_PUSH
MGPResourceDesc TargetedDesc(MGPipeHandle res, MGPipeResourceTarget target, Uint32 width, Uint32 glName) {
MGPResourceDesc desc = BufferDesc(res, width, glName);
desc.Target = static_cast<Uint8>(target);
return desc;
}
MGPHandleOnly KindHandle(MGPipeHandle res, MGPipeKind kind) {
return MGPHandleOnly{res, static_cast<Uint32>(kind), 0};
}
#endif
// ONE SLOT NUMBER, THREE LIVE OBJECTS, THREE RECORDS. This is the case that fails the
// instant the applier goes back to one table: every assertion below is about slot 7 being
// three different things at once, which is exactly what the client allocator hands out.
TEST(ResourceEmit, TheThreeResourceKindsKeepTheirOwnSlotSpaceAndDoNotSeeEachOther) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPipeHandle shared{7, 3};
MGPipeApplyResourceCreate(TargetedDesc(shared, MGPipeResourceTarget::Buffer, 0, 11));
MGPipeApplyResourceCreate(TargetedDesc(shared, MGPipeResourceTarget::Tex2D, 0, 22));
MGPipeApplyResourceCreate(TargetedDesc(shared, MGPipeResourceTarget::Renderbuffer, 0, 33));
ASSERT_GT(MGPipeApplier().Resources.size(), 7u);
ASSERT_GT(MGPipeApplier().TextureResources.size(), 7u);
ASSERT_GT(MGPipeApplier().RenderbufferResources.size(), 7u);
EXPECT_EQ(MGPipeApplier().Resources[7].Desc.GlNameForDiag, 11u);
EXPECT_EQ(MGPipeApplier().TextureResources[7].Desc.GlNameForDiag, 22u);
EXPECT_EQ(MGPipeApplier().RenderbufferResources[7].Desc.GlNameForDiag, 33u);
// A respecify of one of them moves ONE record's serial and one record's extent.
MGPipeApplyResourceRespecify(TargetedDesc(shared, MGPipeResourceTarget::Tex2D, 256, 22), nullptr);
EXPECT_EQ(MGPipeApplier().TextureResources[7].Desc.Width, 256u);
EXPECT_EQ(MGPipeApplier().TextureResources[7].Serial, 1u);
EXPECT_EQ(MGPipeApplier().Resources[7].Desc.Width, 0u) << "a texture respecify moved the buffer";
EXPECT_EQ(MGPipeApplier().Resources[7].Serial, 0u);
EXPECT_EQ(MGPipeApplier().RenderbufferResources[7].Serial, 0u);
// A renderbuffer restorage is the publication D-D2 asks for: the frontend raises no
// version for it, so the emission IS the notice, and the applier holds the new extent.
MGPipeApplyResourceRespecify(TargetedDesc(shared, MGPipeResourceTarget::Renderbuffer, 1024, 33),
nullptr);
EXPECT_EQ(MGPipeApplier().RenderbufferResources[7].Desc.Width, 1024u);
EXPECT_EQ(MGPipeApplier().RenderbufferResources[7].Serial, 1u);
// And a destroy takes the record its KIND names, and only that one.
MGPipeApplyResourceDestroy(KindHandle(shared, MGPipeKind::Texture));
EXPECT_FALSE(MGPipeApplier().TextureResources[7].Live);
EXPECT_EQ(MGPipeApplier().TextureResources[7].Gen, 3u) << "a destroy keeps the generation";
EXPECT_TRUE(MGPipeApplier().Resources[7].Live) << "a texture destroy dropped the buffer's record";
EXPECT_TRUE(MGPipeApplier().RenderbufferResources[7].Live);
EXPECT_EQ(MGPipeApplier().RefusedResourceCalls, 0u);
#endif
}
// Neither branch may fall through to a table it was not named. A target or a kind outside
// the catalogue would otherwise land in whichever table the code happened to reach first,
// and destroy a live object of a kind the call was never about.
TEST(ResourceEmit, AResourceTargetOrKindTheCatalogueDoesNotNameIsRefusedRatherThanRouted) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPipeHandle res{5, 1};
MGPResourceDesc unnamed = BufferDesc(res, 0, 44);
unnamed.Target = static_cast<Uint8>(MGPipeResourceTarget::Count);
ExpectRefusedNaming("resource_create {slot=5, gen=1, glName=44}: the descriptor names no resource "
"target",
[&unnamed]() { MGPipeApplyResourceCreate(unnamed); });
EXPECT_TRUE(MGPipeApplier().Resources.empty());
EXPECT_TRUE(MGPipeApplier().TextureResources.empty());
EXPECT_TRUE(MGPipeApplier().RenderbufferResources.empty());
ExpectRefusedNaming("resource_respecify {slot=5, gen=1, glName=44}: the descriptor names no "
"resource target",
[&unnamed]() { MGPipeApplyResourceRespecify(unnamed, nullptr); });
const MGPHandleOnly wrongKind = KindHandle(res, MGPipeKind::SamplerCso);
ExpectRefusedNaming("resource_destroy {slot=5, gen=1}: the handle names no resource kind",
[&wrongKind]() { MGPipeApplyResourceDestroy(wrongKind); });
EXPECT_EQ(MGPipeApplier().RefusedResourceCalls, 0u)
<< "a corrupt record is not a dropped call and must not be counted as one";
#endif
}
// A texture's resource calls reach NO backend function pointer, and that is the structural
// decision the phase rests on rather than an omission: nothing in the texture family
// dispatches at GL-call time today, so the record IS the publication. A spy table that saw
// one of them would mean P4a had grown an op-table path nobody designed.
TEST(ResourceEmit, NoTextureOrRenderbufferResourceCallReachesTheBackendOpTable) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
g_spy = SpyState{};
MGPipeSetResourceOps(&kSpyOps);
const MGPipeHandle texture{3, 1};
const MGPipeHandle renderbuffer{4, 1};
const Uint8 texels[64] = {};
MGPipeApplyResourceCreate(TargetedDesc(texture, MGPipeResourceTarget::Tex2D, 0, 55));
MGPipeApplyResourceRespecify(TargetedDesc(texture, MGPipeResourceTarget::Tex2D, 8, 55), nullptr);
MGPipeApplyResourceCreate(TargetedDesc(renderbuffer, MGPipeResourceTarget::Renderbuffer, 0, 66));
MGPipeApplyResourceRespecify(TargetedDesc(renderbuffer, MGPipeResourceTarget::Renderbuffer, 8, 66),
nullptr);
MGPSubData upload{};
upload.Res = texture;
upload.Target = static_cast<Uint16>(MGPipeResourceTarget::Tex2D);
upload.UnionBox = MGPBox{0, 0, 0, 4, 4, 1};
MGPipeApplyResourceSubData(upload, texels);
MGPipeApplyResourceDestroy(KindHandle(texture, MGPipeKind::Texture));
MGPipeApplyResourceDestroy(KindHandle(renderbuffer, MGPipeKind::Renderbuffer));
EXPECT_EQ(g_spy.Creates, 0u);
EXPECT_EQ(g_spy.Respecifies, 0u);
EXPECT_EQ(g_spy.SubDatas, 0u);
EXPECT_EQ(g_spy.Destroys, 0u);
// The same five calls on a BUFFER still dispatch, which is what proves the count above
// is the branch working rather than the table being uninstalled.
const MGPipeHandle buffer{3, 1};
MGPipeApplyResourceCreate(BufferDesc(buffer, 0, 77));
MGPipeApplyResourceRespecify(BufferDesc(buffer, 64, 77), nullptr);
MGPipeApplyResourceSubData(BufferWrite(buffer, 0, 16), texels);
MGPipeApplyResourceDestroy(BufferHandle(buffer));
EXPECT_EQ(g_spy.Creates, 1u);
EXPECT_EQ(g_spy.Respecifies, 1u);
EXPECT_EQ(g_spy.SubDatas, 1u);
EXPECT_EQ(g_spy.Destroys, 1u);
#endif
}
#if !MOBILEGL_PIPE_PUSH
// G2 REQUIRES THE PULL AND PUSH ctest NAME SETS TO BE IDENTICAL, name for name, so a
// push-only case cannot be ABSENT from a pull build - it has to be there and SKIP. This
+444
View File
@@ -35,13 +35,19 @@
#include <gtest/gtest.h>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <system_error>
#if defined(_WIN32)
#include <process.h>
#define MGTEST_HAVE_FORK 0
#else
#include <csignal>
#include <sys/wait.h>
#include <unistd.h>
#define MGTEST_HAVE_FORK 1
#endif
#include "Includes.h"
@@ -64,6 +70,92 @@ namespace {
return static_cast<int>(getpid());
#endif
}
#if MOBILEGL_PIPE_PUSH
std::string ReadLog() {
std::ifstream in(g_logPath, std::ios::binary);
std::ostringstream ss;
ss << in.rdbuf();
return ss.str();
}
// A fresh applier per case, BOTH SCOPES, and it takes both because there are two: a reset
// is a make-current and deliberately KEEPS the object records, so a fixture that wants a
// genuinely empty applier has to say the other one as well. Every case is its own process
// under ctest, so this is belt and braces - but running the binary by hand must give the
// same answers as running it under ctest.
struct ApplierGuard {
ApplierGuard() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
~ApplierGuard() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
};
#if MGTEST_HAVE_FORK
struct ChildResult {
int Status = -1;
std::string Log;
};
template <class Body>
ChildResult RunInChild(Body body) {
ChildResult result;
std::error_code ec;
std::filesystem::remove(g_logPath, ec);
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 // MGTEST_HAVE_FORK
// Drives a call a trip wire must REFUSE, and asserts the wire NAMED what it refused. The
// two arms differ by design: a poison or verify build stops the process, so the drive is a
// forked child and the parent reads SIGABRT plus the line out of the log; a shipped push
// build logs and carries on from a defined state, so there the line is read back in process
// and the caller goes on to assert that nothing moved.
template <class Body>
void ExpectRefusedNaming(const char* needle, Body body) {
#if MOBILEGL_PIPE_POISON || MOBILEGL_PIPE_VERIFY
#if MGTEST_HAVE_FORK
const std::string tagged = std::string("Fatal{ProtocolCorruption} ") + needle;
const ChildResult child = RunInChild(body);
EXPECT_TRUE(DiedOfAbort(child)) << DescribeStatus(child) << "; log: " << child.Log;
EXPECT_NE(child.Log.find(tagged), std::string::npos)
<< "the gate fired without naming what it refused; wanted \"" << tagged << "\"; log: " << child.Log;
#else
(void)needle;
(void)body; // no fork on this platform; the verdict here is std::abort()
#endif
#else
const std::string tagged = std::string("ProtocolCorruption ") + needle;
const std::string before = ReadLog();
body();
EXPECT_NE(ReadLog().substr(before.size()).find(tagged), std::string::npos)
<< "the gate refused without saying what it refused; wanted \"" << tagged << "\"";
#endif
}
#endif // MOBILEGL_PIPE_PUSH
} // namespace
// See FramebufferEmitTest's twin for why this is a shape pin rather than a placeholder.
@@ -80,6 +172,358 @@ TEST(SamplerEmit, TheEmitterIsOneNeverDestroyedProcessSingleton) {
#endif
}
// =========================================================================================
// The APPLIER's half of the sampler family (the wire commits'): the CSO record, the
// identity-addressed view record and its back-pointer, and the two unit sets. The emitter's
// half - the content-addressed 256-entry cache, the canonical zero-initialised copy the hash
// and the memcmp run over, the padding that cannot change the hash, borderColorForm crossing -
// is the client package's and lands beside these.
// =========================================================================================
#if MOBILEGL_PIPE_PUSH
namespace {
// Every field carries a value of its own, INCLUDING borderColorForm and all three border
// representations: they are always numerically populated, so the value alone cannot say
// which driver entry point to use and the form is what does.
SamplerParameters SamplerValues(Float lodBias, BorderColorForm form) {
SamplerParameters params{};
params.wrapS = SamplerWrapMode::ClampToEdge;
params.wrapT = SamplerWrapMode::MirroredRepeat;
params.minFilter = SamplerFilterMode::Linear;
params.magFilter = SamplerFilterMode::Nearest;
params.mipmapMode = SamplerMipmapMode::Nearest;
params.lodBias = lodBias;
params.maxAnisotropy = 4.0f;
params.compareMode = SamplerCompareMode::CompareToTexture;
params.borderColor = {0.25f, 0.5f, 0.75f, 1.0f};
params.borderColorI = {-1, 2, -3, 4};
params.borderColorUI = {5u, 6u, 7u, 8u};
params.borderColorForm = form;
return params;
}
MGPSamplerDesc SamplerDesc(MGPipeHandle cso, Uint64 declaredBlobSize) {
MGPSamplerDesc desc{};
desc.Cso = cso;
desc.Parameters.Size = declaredBlobSize;
return desc;
}
MGPSamplerView ViewOf(MGPipeHandle cso, MGPipeHandle texture, Uint16 minLevel) {
MGPSamplerView view{};
view.Cso = cso;
view.Texture = texture;
view.InternalFormat = 0x8058u; // GL_RGBA8
view.Target = static_cast<Uint8>(MGPipeResourceTarget::Tex2D);
view.MinLevel = minLevel;
view.NumLevels = 4;
view.MinLayer = 0;
view.NumLayers = 1;
view.Samples = 1;
return view;
}
MGPHandleOnly SamplerHandle(MGPipeHandle cso) {
return MGPHandleOnly{cso, static_cast<Uint32>(MGPipeKind::SamplerCso), 0};
}
MGPHandleOnly ViewHandle(MGPipeHandle cso) {
return MGPHandleOnly{cso, static_cast<Uint32>(MGPipeKind::SamplerViewCso), 0};
}
MGPResourceDesc TextureDesc(MGPipeHandle res, Uint32 glName) {
MGPResourceDesc desc{};
desc.Resource = res;
desc.Target = static_cast<Uint8>(MGPipeResourceTarget::Tex2D);
desc.GlNameForDiag = glName;
return desc;
}
} // namespace
#endif
// A create starts the record over and leaves Serial at 0 - so a fresh backend twin that starts
// its own synced serial at 0 agrees without either side publishing anything - while a re-issue
// on a LIVE identity counts up, which is how a value change travels on a handle whose
// generation moves only on slot reuse.
TEST(SamplerEmit, ACreateStoresTheParametersByValueAndAReissueOnALiveIdentityCountsUp) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPipeHandle cso{6, 2};
const SamplerParameters first = SamplerValues(0.5f, BorderColorForm::Int);
MGPipeApplyCreateSamplerState(SamplerDesc(cso, 0), &first);
ASSERT_GT(MGPipeApplier().SamplerCsos.size(), 6u);
const MGPipeSamplerCsoRecord& record = MGPipeApplier().SamplerCsos[6];
EXPECT_TRUE(record.Live);
EXPECT_EQ(record.Gen, 2u);
EXPECT_EQ(record.Serial, 0u) << "a create is not a mutation";
EXPECT_EQ(record.Params.wrapT, SamplerWrapMode::MirroredRepeat);
EXPECT_EQ(record.Params.magFilter, SamplerFilterMode::Nearest);
EXPECT_EQ(record.Params.compareMode, SamplerCompareMode::CompareToTexture);
EXPECT_FLOAT_EQ(record.Params.lodBias, 0.5f);
EXPECT_FLOAT_EQ(record.Params.borderColor.z(), 0.75f);
EXPECT_EQ(record.Params.borderColorI.x(), -1);
EXPECT_EQ(record.Params.borderColorUI.w(), 8u);
EXPECT_EQ(record.Params.borderColorForm, BorderColorForm::Int)
<< "borderColorForm crosses; without it the backend cannot choose an entry point";
const SamplerParameters second = SamplerValues(1.5f, BorderColorForm::Uint);
MGPipeApplyCreateSamplerState(SamplerDesc(cso, sizeof(SamplerParameters)), &second);
EXPECT_EQ(MGPipeApplier().SamplerCsos[6].Serial, 1u);
EXPECT_FLOAT_EQ(MGPipeApplier().SamplerCsos[6].Params.lodBias, 1.5f);
EXPECT_EQ(MGPipeApplier().SamplerCsos[6].Params.borderColorForm, BorderColorForm::Uint);
// A RECYCLED SLOT STARTS OVER. Inheriting one field of the previous occupant - a serial, a
// filter - is precisely how a sampler at a recycled slot inherits its predecessor's state.
const SamplerParameters third = SamplerValues(2.5f, BorderColorForm::Float);
MGPipeApplyCreateSamplerState(SamplerDesc(MGPipeHandle{6, 3}, 0), &third);
EXPECT_EQ(MGPipeApplier().SamplerCsos[6].Gen, 3u);
EXPECT_EQ(MGPipeApplier().SamplerCsos[6].Serial, 0u) << "a recycled slot kept its predecessor's serial";
EXPECT_FLOAT_EQ(MGPipeApplier().SamplerCsos[6].Params.lodBias, 2.5f);
#endif
}
// The one Blob rule, on this family's own blob: a non-zero declared length must be exactly one
// SamplerParameters, a zero means "this record does not declare its blob" - which is what a
// monolith emission is - and either way the bytes read are bounded by the TYPE.
TEST(SamplerEmit, ARecordThatDoesNotDescribeItsOwnParametersIsRefusedNamingTheLength) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const SamplerParameters values = SamplerValues(0.0f, BorderColorForm::Float);
const MGPSamplerDesc lying = SamplerDesc(MGPipeHandle{4, 1}, sizeof(SamplerParameters) + 1);
ExpectRefusedNaming("create_sampler_state {slot=4, gen=1}: the declared blob length is not one "
"SamplerParameters",
[&lying, &values]() { MGPipeApplyCreateSamplerState(lying, &values); });
EXPECT_TRUE(MGPipeApplier().SamplerCsos.empty());
const MGPSamplerDesc undeclared = SamplerDesc(MGPipeHandle{4, 1}, 0);
ExpectRefusedNaming("create_sampler_state {slot=4, gen=1}: the record declares no parameters and "
"carries none",
[&undeclared]() { MGPipeApplyCreateSamplerState(undeclared, nullptr); });
EXPECT_TRUE(MGPipeApplier().SamplerCsos.empty());
// And the slot bound, which is the one number in the family that reaches an allocator.
const MGPSamplerDesc pastTheBound = SamplerDesc(MGPipeHandle{kMGPipeMaxSamplerCsoSlots, 1}, 0);
ExpectRefusedNaming("create_sampler_state {slot=65536, gen=1}: the slot is outside the record table's "
"bound",
[&pastTheBound, &values]() { MGPipeApplyCreateSamplerState(pastTheBound, &values); });
EXPECT_TRUE(MGPipeApplier().SamplerCsos.empty()) << "the table was grown by a corrupt slot";
#endif
}
// A death notice on a record the applier does not have is the ONE refusal a legal sequence
// produces - the teardown order - so it stays a defined no-op, and it is COUNTED because
// MOBILEGL_ASSERT compiles out at INFO and every build that matters is one.
TEST(SamplerEmit, ADeleteDropsTheRecordAndAStaleNoticeIsCountedRatherThanSilentlyDropped) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPipeHandle cso{3, 7};
const SamplerParameters values = SamplerValues(0.0f, BorderColorForm::Float);
MGPipeApplyCreateSamplerState(SamplerDesc(cso, 0), &values);
ASSERT_TRUE(MGPipeApplier().SamplerCsos[3].Live);
MGPipeApplyDeleteSamplerState(SamplerHandle(cso));
EXPECT_FALSE(MGPipeApplier().SamplerCsos[3].Live);
EXPECT_EQ(MGPipeApplier().SamplerCsos[3].Gen, 7u) << "a destroy keeps the generation";
EXPECT_EQ(MGPipeApplier().SamplerCsos[3].Params.wrapT, SamplerWrapMode::Repeat)
<< "a stale read of a deleted slot must find nothing, not the state that used to be there";
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 0u);
// The second notice - the one a teardown produces - is refused and counted.
MGPipeApplyDeleteSamplerState(SamplerHandle(cso));
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 1u);
MGPipeApplyDeleteSamplerState(SamplerHandle(MGPipeHandle{3, 8}));
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 2u);
#endif
}
// A sampler view is IDENTITY-addressed one per texture object, minted off that object's
// lifetime id, so a restriction change is a re-issue on the same handle rather than a new one.
// The texture's own back-pointer is written here and cleared by the delete, and both are silent
// lookups: the texture bit and the sampler bit are independent, so a view arriving without its
// texture is an ordering fact and not a refusal.
TEST(SamplerEmit, AViewIsReissuedOnTheSameHandleAndKeepsItsTexturesBackPointerInStep) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPipeHandle texture{5, 1};
const MGPipeHandle view{9, 2};
MGPipeApplyResourceCreate(TextureDesc(texture, 42));
MGPipeApplyCreateSamplerView(ViewOf(view, texture, 0));
ASSERT_GT(MGPipeApplier().SamplerViewCsos.size(), 9u);
EXPECT_TRUE(MGPipeApplier().SamplerViewCsos[9].Live);
EXPECT_EQ(MGPipeApplier().SamplerViewCsos[9].Serial, 0u);
EXPECT_EQ(MGPipeApplier().SamplerViewCsos[9].View.InternalFormat, 0x8058u);
EXPECT_EQ(MGPipeApplier().SamplerViewCsos[9].View.NumLevels, 4u);
EXPECT_EQ(MGPipeApplier().TextureResources[5].ViewCso, view)
<< "the texture's back-pointer to its one view was not written";
// A restriction change: same handle, serial up, nothing started over.
MGPipeApplyCreateSamplerView(ViewOf(view, texture, 2));
EXPECT_EQ(MGPipeApplier().SamplerViewCsos[9].Serial, 1u);
EXPECT_EQ(MGPipeApplier().SamplerViewCsos[9].View.MinLevel, 2u);
// A view whose texture this applier has not been told about is stored anyway - refusing it
// would make one legal A/B arm drop every view - and it counts no refusal.
MGPipeApplyCreateSamplerView(ViewOf(MGPipeHandle{10, 1}, MGPipeHandle{77, 1}, 0));
EXPECT_TRUE(MGPipeApplier().SamplerViewCsos[10].Live);
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 0u);
MGPipeApplyDeleteSamplerView(ViewHandle(view));
EXPECT_FALSE(MGPipeApplier().SamplerViewCsos[9].Live);
EXPECT_EQ(MGPipeApplier().SamplerViewCsos[9].Gen, 2u);
EXPECT_EQ(MGPipeApplier().TextureResources[5].ViewCso, kMGPipeNullHandle)
<< "the texture kept a back-pointer to a view that is gone";
MGPipeApplyDeleteSamplerView(ViewHandle(view));
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 1u);
#endif
}
// THE WINDOW IS THE BOUND AND ENTRIES OUTSIDE IT ARE NOT CLEARED: a set that names four units
// has said nothing about the other 188, and clearing them would unbind textures the client
// never mentioned. Deleting the entry loop, the window gate or either serial bump leaves this
// red.
TEST(SamplerEmit, TheTwoUnitSetsLandInTheirWindowAndLeaveEverythingOutsideItAlone) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
MGPBoundView views[2] = {};
views[0].View = MGPipeHandle{1, 1};
views[0].Texture = MGPipeHandle{2, 1};
views[0].Unit = 4;
views[1].View = kMGPipeNullHandle; // a unit the program does not resolve is legal
views[1].Texture = MGPipeHandle{3, 1};
views[1].Unit = 5;
MGPSamplerViews viewHeader{};
viewHeader.Start = 4;
viewHeader.Count = 2;
viewHeader.ContentHash = 0xABCDu;
const Uint64 viewSerial = MGPipeApplier().SamplerViewsSerial;
MGPipeApplySetSamplerViews(viewHeader, views);
EXPECT_EQ(MGPipeApplier().SamplerViewStart, 4u);
EXPECT_EQ(MGPipeApplier().SamplerViewCount, 2u);
EXPECT_EQ(MGPipeApplier().BoundSamplerViews[4].View, (MGPipeHandle{1, 1}));
EXPECT_EQ(MGPipeApplier().BoundSamplerViews[5].Texture, (MGPipeHandle{3, 1}));
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundSamplerViews[5].View));
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundSamplerViews[3].View)) << "the set wrote below its window";
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundSamplerViews[6].View)) << "the set wrote above its window";
EXPECT_GT(MGPipeApplier().SamplerViewsSerial, viewSerial);
MGPipeHandle states[2] = {MGPipeHandle{8, 1}, kMGPipeNullHandle};
MGPSamplerStates stateHeader{};
stateHeader.Start = 4;
stateHeader.Count = 2;
const Uint64 stateSerial = MGPipeApplier().SamplerStatesSerial;
MGPipeApplyBindSamplerStates(stateHeader, states);
EXPECT_EQ(MGPipeApplier().BoundSamplerStates[4], (MGPipeHandle{8, 1}));
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundSamplerStates[5]))
<< "a unit with no sampler object carries a null CSO and the texture's built-in one applies";
EXPECT_EQ(MGPipeApplier().SamplerStateCount, 2u);
EXPECT_GT(MGPipeApplier().SamplerStatesSerial, stateSerial);
EXPECT_EQ(MGPipeApplier().SamplerViewsSerial, viewSerial + 1)
<< "one set moved the other set's serial; the three are independent";
// A NARROWER SET DOES NOT CLEAR WHAT IT DOES NOT NAME - "the last set as received".
MGPSamplerViews narrow{};
narrow.Start = 4;
narrow.Count = 1;
MGPipeApplySetSamplerViews(narrow, views);
EXPECT_EQ(MGPipeApplier().SamplerViewCount, 1u);
EXPECT_EQ(MGPipeApplier().BoundSamplerViews[5].Texture, (MGPipeHandle{3, 1}))
<< "the entry outside the new window was cleared";
#endif
}
// The window gate itself, at the bound and one past it, plus the null-tail arm. A header that
// describes more than its destination can hold is the same class of fault as a blob outside
// its segment, and the destination here is the merged 192-unit space.
TEST(SamplerEmit, AUnitWindowPastTheMergedUnitSpaceIsRefusedRatherThanTruncated) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
MGPBoundView entry{};
entry.Texture = MGPipeHandle{2, 1};
// The positive control: a window ending EXACTLY at the bound is fine.
MGPSamplerViews exact{};
exact.Start = kMGPipeMaxTextureUnits - 1;
exact.Count = 1;
MGPipeApplySetSamplerViews(exact, &entry);
ASSERT_EQ(MGPipeApplier().SamplerViewCount, 1u);
const Uint64 serialBefore = MGPipeApplier().SamplerViewsSerial;
MGPSamplerViews past{};
past.Start = kMGPipeMaxTextureUnits - 1;
past.Count = 2;
past.ContentHash = 7;
ExpectRefusedNaming("set_sampler_views {start=191, count=2, hash=7}: the window runs past the merged "
"texture-unit space",
[&past, &entry]() { MGPipeApplySetSamplerViews(past, &entry); });
MGPSamplerStates statesPast{};
statesPast.Start = 0;
statesPast.Count = kMGPipeMaxTextureUnits + 1;
ExpectRefusedNaming("bind_sampler_states {start=0, count=193, hash=0}: the window runs past the merged "
"texture-unit space",
[&statesPast]() {
MGPipeHandle one = kMGPipeNullHandle;
MGPipeApplyBindSamplerStates(statesPast, &one);
});
MGPSamplerViews noTail{};
noTail.Start = 0;
noTail.Count = 3;
ExpectRefusedNaming("set_sampler_views {start=0, count=3, hash=0}: a non-empty set carries no entries",
[&noTail]() { MGPipeApplySetSamplerViews(noTail, nullptr); });
EXPECT_EQ(MGPipeApplier().SamplerViewsSerial, serialBefore) << "a refused set moved the serial";
EXPECT_EQ(MGPipeApplier().SamplerViewStart, kMGPipeMaxTextureUnits - 1);
#endif
}
// D-J4 for this family: the CSO and the view are OBJECT records and survive a make-current;
// the two unit sets are WORKING state and do not, and their serials advance rather than
// restarting.
TEST(SamplerEmit, AMakeCurrentTakesTheUnitSetsAndLeavesTheCsoAndViewRecordsStanding) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const SamplerParameters values = SamplerValues(3.0f, BorderColorForm::Float);
MGPipeApplyCreateSamplerState(SamplerDesc(MGPipeHandle{2, 1}, 0), &values);
MGPipeApplyCreateSamplerView(ViewOf(MGPipeHandle{3, 1}, MGPipeHandle{4, 1}, 1));
MGPBoundView entry{};
entry.Texture = MGPipeHandle{4, 1};
MGPSamplerViews header{};
header.Count = 1;
MGPipeApplySetSamplerViews(header, &entry);
const Uint64 viewsSerial = MGPipeApplier().SamplerViewsSerial;
const Uint64 statesSerial = MGPipeApplier().SamplerStatesSerial;
MGPipeApplierReset();
EXPECT_TRUE(MGPipeApplier().SamplerCsos[2].Live) << "a make-current dropped a share-group CSO record";
EXPECT_FLOAT_EQ(MGPipeApplier().SamplerCsos[2].Params.lodBias, 3.0f);
EXPECT_TRUE(MGPipeApplier().SamplerViewCsos[3].Live);
EXPECT_EQ(MGPipeApplier().SamplerViewCount, 0u) << "the unit set is per context and must be cleared";
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundSamplerViews[0].Texture));
EXPECT_GT(MGPipeApplier().SamplerViewsSerial, viewsSerial);
EXPECT_GT(MGPipeApplier().SamplerStatesSerial, statesSerial);
#endif
}
int main(int argc, char** argv) {
namespace fs = std::filesystem;
const fs::path path =
+411
View File
@@ -39,13 +39,19 @@
#include <gtest/gtest.h>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <system_error>
#if defined(_WIN32)
#include <process.h>
#define MGTEST_HAVE_FORK 0
#else
#include <csignal>
#include <sys/wait.h>
#include <unistd.h>
#define MGTEST_HAVE_FORK 1
#endif
#include "Includes.h"
@@ -68,6 +74,92 @@ namespace {
return static_cast<int>(getpid());
#endif
}
#if MOBILEGL_PIPE_PUSH
std::string ReadLog() {
std::ifstream in(g_logPath, std::ios::binary);
std::ostringstream ss;
ss << in.rdbuf();
return ss.str();
}
// A fresh applier per case, BOTH SCOPES, and it takes both because there are two: a reset
// is a make-current and deliberately KEEPS the object records, so a fixture that wants a
// genuinely empty applier has to say the other one as well. Every case is its own process
// under ctest, so this is belt and braces - but running the binary by hand must give the
// same answers as running it under ctest.
struct ApplierGuard {
ApplierGuard() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
~ApplierGuard() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
};
#if MGTEST_HAVE_FORK
struct ChildResult {
int Status = -1;
std::string Log;
};
template <class Body>
ChildResult RunInChild(Body body) {
ChildResult result;
std::error_code ec;
std::filesystem::remove(g_logPath, ec);
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 // MGTEST_HAVE_FORK
// Drives a call a trip wire must REFUSE, and asserts the wire NAMED what it refused. The
// two arms differ by design: a poison or verify build stops the process, so the drive is a
// forked child and the parent reads SIGABRT plus the line out of the log; a shipped push
// build logs and carries on from a defined state, so there the line is read back in process
// and the caller goes on to assert that nothing moved.
template <class Body>
void ExpectRefusedNaming(const char* needle, Body body) {
#if MOBILEGL_PIPE_POISON || MOBILEGL_PIPE_VERIFY
#if MGTEST_HAVE_FORK
const std::string tagged = std::string("Fatal{ProtocolCorruption} ") + needle;
const ChildResult child = RunInChild(body);
EXPECT_TRUE(DiedOfAbort(child)) << DescribeStatus(child) << "; log: " << child.Log;
EXPECT_NE(child.Log.find(tagged), std::string::npos)
<< "the gate fired without naming what it refused; wanted \"" << tagged << "\"; log: " << child.Log;
#else
(void)needle;
(void)body; // no fork on this platform; the verdict here is std::abort()
#endif
#else
const std::string tagged = std::string("ProtocolCorruption ") + needle;
const std::string before = ReadLog();
body();
EXPECT_NE(ReadLog().substr(before.size()).find(tagged), std::string::npos)
<< "the gate refused without saying what it refused; wanted \"" << tagged << "\"";
#endif
}
#endif // MOBILEGL_PIPE_PUSH
} // namespace
// See FramebufferEmitTest's twin for why this is a shape pin rather than a placeholder: a
@@ -83,6 +175,325 @@ TEST(TextureEmit, TheEmitterIsOneNeverDestroyedProcessSingleton) {
#endif
}
// =========================================================================================
// The APPLIER's half of the texture family (the wire commits'): set_texture_params on the
// texture's own record, and the sub-data validator plus the pending-upload set that replaces
// the frontend dirty flags the client clears at emission. The emitter's half - the descriptor
// builder for every target, the sticky bind mask, the drain list, the level-shadow strides -
// is the client package's and lands beside these.
// =========================================================================================
#if MOBILEGL_PIPE_PUSH
namespace {
constexpr Uint16 kTex2D = static_cast<Uint16>(MGPipeResourceTarget::Tex2D);
MGPResourceDesc TextureDesc(MGPipeHandle res, Uint32 width, Uint32 glName) {
MGPResourceDesc desc{};
desc.Resource = res;
desc.Target = static_cast<Uint8>(MGPipeResourceTarget::Tex2D);
desc.Width = width;
desc.Height = width;
desc.GlNameForDiag = glName;
return desc;
}
// Every field carries a value of its own so a body that stored the wrong one is visible BY
// FIELD, which is what the family's descriptor-consistency control needs of it.
MGPTextureParams TextureParams(MGPipeHandle res, MGPipeHandle builtinSampler, Uint16 baseLevel) {
MGPTextureParams params{};
params.Res = res;
params.BuiltinSampler = builtinSampler;
params.BaseLevel = baseLevel;
params.MaxLevel = 7;
params.Swizzle[0] = 1;
params.Swizzle[1] = 2;
params.Swizzle[2] = 3;
params.Swizzle[3] = 4;
params.DepthStencilMode = 5;
params.ForceResync = 1;
params.SamplerResync = 1;
params.MinLod = -2.0f;
params.MaxLod = 9.0f;
params.LodBias = 0.5f;
return params;
}
MGPSubData TextureUpload(MGPipeHandle res, Uint16 level, const MGPBox& box, Uint32 regionCount) {
MGPSubData record{};
record.Res = res;
record.Target = kTex2D;
record.Level = level;
record.SourceIsVerbatimLevelShadow = 1;
record.UnionBox = box;
record.RegionCount = regionCount;
return record;
}
MGPSubRegion Region(Int32 x, Int32 y, Uint32 w, Uint32 h) {
MGPSubRegion region{};
region.X = x;
region.Y = y;
region.Z = 0;
region.W = w;
region.H = h;
region.D = 1;
region.SrcOffset = static_cast<Uint64>(y) * 64 + static_cast<Uint64>(x) * 4;
region.SrcRowStride = 256;
region.SrcSliceStride = 0;
return region;
}
const MGPipeResourceRecord& TextureRecordOf(Uint32 slot) {
EXPECT_GT(MGPipeApplier().TextureResources.size(), static_cast<SizeT>(slot));
return MGPipeApplier().TextureResources[slot];
}
} // namespace
#endif
// set_texture_params IS ADDRESSED BY RESOURCE AND BY NOTHING ELSE, which is the whole reason
// the call exists: a texture that is only an FBO attachment, only an image-unit binding or
// only a glCopyImageSubData endpoint has no sampler view to hang its parameters on. Deleting
// the store or the ParamsSerial bump leaves this red.
TEST(TextureEmit, ATexturesParametersLandOnItsOwnRecordAndMoveOnlyTheirOwnSerial) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPipeHandle texture{7, 3};
const MGPipeHandle sampler{2, 1};
MGPipeApplyResourceCreate(TextureDesc(texture, 0, 41));
MGPipeApplyResourceRespecify(TextureDesc(texture, 64, 41), nullptr);
ASSERT_EQ(TextureRecordOf(7).ParamsSerial, 0u) << "a create and a respecify are not a parameter push";
MGPipeApplySetTextureParams(TextureParams(texture, sampler, 2));
const MGPipeResourceRecord& record = TextureRecordOf(7);
EXPECT_EQ(record.Params.BuiltinSampler, sampler);
EXPECT_EQ(record.Params.BaseLevel, 2u);
EXPECT_EQ(record.Params.MaxLevel, 7u);
EXPECT_EQ(record.Params.Swizzle[2], 3u);
EXPECT_EQ(record.Params.DepthStencilMode, 5u);
EXPECT_EQ(record.Params.ForceResync, 1u);
EXPECT_EQ(record.Params.SamplerResync, 1u) << "the second resync bit is carried, not dropped";
EXPECT_FLOAT_EQ(record.Params.MinLod, -2.0f);
EXPECT_FLOAT_EQ(record.Params.LodBias, 0.5f);
EXPECT_EQ(record.ParamsSerial, 1u);
EXPECT_EQ(record.Serial, 1u) << "a parameter push is not a storage mutation and must not move Serial";
MGPipeApplySetTextureParams(TextureParams(texture, sampler, 3));
EXPECT_EQ(TextureRecordOf(7).Params.BaseLevel, 3u);
EXPECT_EQ(TextureRecordOf(7).ParamsSerial, 2u);
// A stale generation resolves to nothing: the call is a DEFINED no-op and it is COUNTED,
// because MOBILEGL_ASSERT compiles out at INFO and a no-op nobody can see is a dropped
// parameter push nobody can see.
const Uint64 refusedBefore = MGPipeApplier().RefusedObjectCalls;
MGPipeApplySetTextureParams(TextureParams(MGPipeHandle{7, 4}, sampler, 6));
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, refusedBefore + 1);
EXPECT_EQ(TextureRecordOf(7).Params.BaseLevel, 3u) << "a stale handle wrote the live record";
EXPECT_EQ(TextureRecordOf(7).ParamsSerial, 2u);
// And a BUFFER of the same slot is not a texture: the two tables are independent, so this
// is a refusal rather than a parameter push onto somebody else's record.
MGPipeApplySetTextureParams(TextureParams(MGPipeHandle{9, 1}, sampler, 1));
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, refusedBefore + 2);
#endif
}
// EVERY ITextureObject OWNS A SamplerObject, so a null built-in sampler CSO is not "no
// sampler" - it is a record that would have the backend sample with whatever filter and wrap
// state the unit last left behind. It is the corrupt-record verdict rather than the dropped-
// call one, so it must NOT be counted as a refusal.
TEST(TextureEmit, ARecordWithNoBuiltinSamplerCsoIsRefusedNamingTheTexture) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPipeHandle texture{6, 2};
MGPipeApplyResourceCreate(TextureDesc(texture, 0, 77));
MGPipeApplySetTextureParams(TextureParams(texture, MGPipeHandle{2, 1}, 1));
ASSERT_EQ(TextureRecordOf(6).ParamsSerial, 1u);
const Uint64 refusedBefore = MGPipeApplier().RefusedObjectCalls;
const MGPTextureParams noSampler = TextureParams(texture, kMGPipeNullHandle, 4);
ExpectRefusedNaming("set_texture_params {slot=6, gen=2, glName=77}: the record names no built-in "
"sampler CSO",
[&noSampler]() { MGPipeApplySetTextureParams(noSampler); });
EXPECT_EQ(TextureRecordOf(6).Params.BaseLevel, 1u) << "a refused record was stored anyway";
EXPECT_EQ(TextureRecordOf(6).ParamsSerial, 1u);
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, refusedBefore)
<< "a corrupt record is not a dropped call and must not be counted as one";
#endif
}
// D-D5's safety net. The client clears its own dirty flags AT EMISSION and the backend's
// upload loop has bail arms that would otherwise lose exactly those texels, so the emitted
// shape accumulates SERVER-SIDE: boxes union, rect lists concatenate, and the moment either
// side says "box only" the entry becomes box only - which is the frontend's own model, where
// zero rects means "upload the union box instead" and covers every reason at once.
TEST(TextureEmit, AnAccumulatedUploadUnionsItsBoxesAndCollapsesToTheBoxWhenARectListCannotDescribeIt) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPipeHandle texture{4, 1};
const Uint8 texels[4096] = {};
MGPipeApplyResourceCreate(TextureDesc(texture, 0, 88));
MGPipeApplyResourceRespecify(TextureDesc(texture, 64, 88), nullptr);
const MGPSubRegion first[2] = {Region(0, 0, 4, 4), Region(8, 8, 4, 4)};
MGPipeApplyResourceSubData(TextureUpload(texture, 0, MGPBox{0, 0, 0, 12, 12, 1}, 2), texels, first);
ASSERT_EQ(TextureRecordOf(4).PendingUploads.size(), 1u);
EXPECT_EQ(TextureRecordOf(4).PendingUploads[0].UploadTarget, kTex2D);
EXPECT_EQ(TextureRecordOf(4).PendingUploads[0].Level, 0u);
EXPECT_EQ(TextureRecordOf(4).PendingUploads[0].UnionBox.W, 12u);
ASSERT_EQ(TextureRecordOf(4).PendingUploads[0].Regions.size(), 2u);
EXPECT_EQ(TextureRecordOf(4).PendingUploads[0].Regions[1].X, 8);
EXPECT_EQ(TextureRecordOf(4).PendingUploads[0].Regions[1].SrcRowStride, 256u)
<< "the strides are CARRIED, never inferred: the pointer comparison they replace cannot "
"survive a split";
EXPECT_EQ(TextureRecordOf(4).Serial, 2u) << "an accepted upload moves the record's serial";
// A second emission behind a backend bail: the boxes union and the lists concatenate.
const MGPSubRegion second[1] = {Region(16, 0, 8, 8)};
MGPipeApplyResourceSubData(TextureUpload(texture, 0, MGPBox{16, 0, 0, 8, 8, 1}, 1), texels, second);
ASSERT_EQ(TextureRecordOf(4).PendingUploads.size(), 1u) << "the (target, level) key split in two";
EXPECT_EQ(TextureRecordOf(4).PendingUploads[0].UnionBox.X, 0);
EXPECT_EQ(TextureRecordOf(4).PendingUploads[0].UnionBox.W, 24u);
EXPECT_EQ(TextureRecordOf(4).PendingUploads[0].UnionBox.H, 12u);
EXPECT_EQ(TextureRecordOf(4).PendingUploads[0].Regions.size(), 3u);
// A contribution with NO regions means "the box is the whole story", and the accumulated
// entry has to say the same thing afterwards or the box would cover texels the rect list
// does not name.
MGPipeApplyResourceSubData(TextureUpload(texture, 0, MGPBox{0, 0, 0, 64, 64, 1}, 0), texels);
ASSERT_EQ(TextureRecordOf(4).PendingUploads.size(), 1u);
EXPECT_TRUE(TextureRecordOf(4).PendingUploads[0].Regions.empty())
<< "a box-only contribution left a rect list that no longer covers the box";
EXPECT_EQ(TextureRecordOf(4).PendingUploads[0].UnionBox.W, 64u);
// A different level is a different key, and a different upload target would be too.
MGPipeApplyResourceSubData(TextureUpload(texture, 3, MGPBox{0, 0, 0, 8, 8, 1}, 0), texels);
ASSERT_EQ(TextureRecordOf(4).PendingUploads.size(), 2u);
EXPECT_EQ(TextureRecordOf(4).PendingUploads[1].Level, 3u);
EXPECT_EQ(TextureRecordOf(4).PendingUploads[0].UnionBox.W, 64u) << "level 3 rewrote level 0's box";
#endif
}
// The texture half of the sub-data validator. Each of its four statements is about a record
// that would make the server upload texels it was never told about, or read a tail it was not
// given; removing any one of them leaves this red.
TEST(TextureEmit, TheSubDataValidatorRefusesALevelABoxAndARegionTheRecordCannotDescribe) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPipeHandle texture{5, 2};
const Uint8 texels[4096] = {};
MGPipeApplyResourceCreate(TextureDesc(texture, 0, 99));
MGPipeApplyResourceRespecify(TextureDesc(texture, 64, 99), nullptr);
// Positive control: the last legal level, a whole-level box and a region exactly filling
// it are all fine, so what follows is refusing the value and not the arithmetic round it.
const MGPSubRegion exact[1] = {Region(0, 0, 8, 8)};
MGPipeApplyResourceSubData(TextureUpload(texture, 31, MGPBox{0, 0, 0, 8, 8, 1}, 1), texels, exact);
ASSERT_EQ(TextureRecordOf(5).PendingUploads.size(), 1u);
const Uint64 serialBefore = TextureRecordOf(5).Serial;
const MGPSubData deepLevel = TextureUpload(texture, 32, MGPBox{0, 0, 0, 8, 8, 1}, 0);
ExpectRefusedNaming("resource_subdata {slot=5, gen=2, glName=99}: the level is above the bound any "
"texture's storage can have",
[&deepLevel, &texels]() { MGPipeApplyResourceSubData(deepLevel, texels); });
const MGPSubData negative = TextureUpload(texture, 0, MGPBox{-1, 0, 0, 8, 8, 1}, 0);
ExpectRefusedNaming("resource_subdata {slot=5, gen=2, glName=99}: the union box has a negative origin "
"or runs past the bound one record can encode",
[&negative, &texels]() { MGPipeApplyResourceSubData(negative, texels); });
const MGPSubData missingTail = TextureUpload(texture, 0, MGPBox{0, 0, 0, 8, 8, 1}, 2);
ExpectRefusedNaming("resource_subdata {slot=5, gen=2, glName=99}: the record declares sub-regions and "
"carries none",
[&missingTail, &texels]() { MGPipeApplyResourceSubData(missingTail, texels); });
// THE ONE INVARIANT THAT MATTERS: the union box IS the union of the regions. The server
// picks the upload shape from the pair, so a region outside the box means the box misses
// its texels and the region writes where the box never said it would.
const MGPSubRegion outside[1] = {Region(16, 0, 4, 4)};
const MGPSubData escapes = TextureUpload(texture, 0, MGPBox{0, 0, 0, 8, 8, 1}, 1);
ExpectRefusedNaming("resource_subdata {slot=5, gen=2, glName=99}: a sub-region is not inside the union "
"box the record declares",
[&escapes, &texels, &outside]() {
MGPipeApplyResourceSubData(escapes, texels, outside);
});
const MGPSubData nothing = TextureUpload(texture, 0, MGPBox{0, 0, 0, 0, 0, 0}, 0);
ExpectRefusedNaming("resource_subdata {slot=5, gen=2, glName=99}: the record describes no texels at all",
[&nothing, &texels]() { MGPipeApplyResourceSubData(nothing, texels); });
EXPECT_EQ(TextureRecordOf(5).PendingUploads.size(), 1u)
<< "a refused record was accumulated anyway";
EXPECT_EQ(TextureRecordOf(5).Serial, serialBefore) << "not one refusal may move the serial";
#endif
}
// A respecify redefines the store, so the boxes and rects that describe the level it replaces
// go with it - a box kept across a shrink would have the backend upload past the end of the
// new level. Nothing is lost by it: the frontend entry points that respecify a texture re-mark
// the levels they define.
TEST(TextureEmit, ARespecifyDropsThePendingUploadsAgainstTheStorageItReplaces) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPipeHandle texture{3, 1};
const Uint8 texels[4096] = {};
MGPipeApplyResourceCreate(TextureDesc(texture, 0, 55));
MGPipeApplyResourceRespecify(TextureDesc(texture, 64, 55), nullptr);
MGPipeApplyResourceSubData(TextureUpload(texture, 0, MGPBox{0, 0, 0, 64, 64, 1}, 0), texels);
ASSERT_EQ(TextureRecordOf(3).PendingUploads.size(), 1u);
MGPipeApplyResourceRespecify(TextureDesc(texture, 8, 55), nullptr);
EXPECT_TRUE(TextureRecordOf(3).PendingUploads.empty())
<< "a 64-wide box survived onto an 8-wide store";
EXPECT_EQ(TextureRecordOf(3).Desc.Width, 8u);
#endif
}
// D-J4, for the kind that made the rule matter: a TEXTURE lives in a share group exactly as a
// buffer does, so its record - and the parameters and the pending uploads that ride on it -
// outlives a make-current, and only the applier's own teardown takes it.
TEST(TextureEmit, TheTextureRecordAndItsParamsAndPendingUploadsSurviveAMakeCurrent) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPipeHandle texture{2, 5};
const Uint8 texels[4096] = {};
MGPipeApplyResourceCreate(TextureDesc(texture, 0, 66));
MGPipeApplyResourceRespecify(TextureDesc(texture, 32, 66), nullptr);
MGPipeApplySetTextureParams(TextureParams(texture, MGPipeHandle{1, 1}, 2));
MGPipeApplyResourceSubData(TextureUpload(texture, 0, MGPBox{0, 0, 0, 32, 32, 1}, 0), texels);
MGPipeApplierReset(); // the make-current
ASSERT_TRUE(TextureRecordOf(2).Live) << "a make-current dropped a share-group object's record";
EXPECT_EQ(TextureRecordOf(2).Desc.Width, 32u);
EXPECT_EQ(TextureRecordOf(2).Params.BaseLevel, 2u);
EXPECT_EQ(TextureRecordOf(2).ParamsSerial, 1u);
ASSERT_EQ(TextureRecordOf(2).PendingUploads.size(), 1u)
<< "the pending uploads are the safety net for a backend bail and cannot be per context";
// The write that follows the switch still lands, which is the whole point of the rule.
MGPipeApplyResourceSubData(TextureUpload(texture, 1, MGPBox{0, 0, 0, 16, 16, 1}, 0), texels);
EXPECT_EQ(TextureRecordOf(2).PendingUploads.size(), 2u);
EXPECT_EQ(MGPipeApplier().RefusedResourceCalls, 0u);
// And the teardown scope - the only other thing that clears a record - does take it.
MGPipeApplierReleaseObjectRecords();
EXPECT_TRUE(MGPipeApplier().TextureResources.empty());
#endif
}
int main(int argc, char** argv) {
namespace fs = std::filesystem;
const fs::path path =