From ae1a1c503f9821b8d6866956b7f0d5061302c32a Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Tue, 8 Sep 2026 13:07:40 -0400 Subject: [PATCH] [Test] (Pipe): pin every P4a record's lifecycle, its bounds gate and what a make-current does and does not clear --- .../MG_Test/Pipe/CompositeResolverTest.cpp | 237 ++++++++++ MobileGL/MG_Test/Pipe/FramebufferEmitTest.cpp | 249 ++++++++++ MobileGL/MG_Test/Pipe/ImageEmitTest.cpp | 248 ++++++++++ MobileGL/MG_Test/Pipe/ProgramEmitTest.cpp | 397 ++++++++++++++++ MobileGL/MG_Test/Pipe/ResourceEmitTest.cpp | 148 ++++++ MobileGL/MG_Test/Pipe/SamplerEmitTest.cpp | 444 ++++++++++++++++++ MobileGL/MG_Test/Pipe/TextureEmitTest.cpp | 411 ++++++++++++++++ 7 files changed, 2134 insertions(+) diff --git a/MobileGL/MG_Test/Pipe/CompositeResolverTest.cpp b/MobileGL/MG_Test/Pipe/CompositeResolverTest.cpp index aee549bd..f4ba7db5 100644 --- a/MobileGL/MG_Test/Pipe/CompositeResolverTest.cpp +++ b/MobileGL/MG_Test/Pipe/CompositeResolverTest.cpp @@ -32,19 +32,29 @@ #include #include +#include +#include #include #include #if defined(_WIN32) #include +#define MGTEST_HAVE_FORK 0 #else +#include +#include #include +#define MGTEST_HAVE_FORK 1 #endif #include "Includes.h" #include #if MOBILEGL_PIPE_PUSH #include +#include +// 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 #endif using namespace MobileGL; @@ -60,6 +70,92 @@ namespace { return static_cast(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 + 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 + 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(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(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 = diff --git a/MobileGL/MG_Test/Pipe/FramebufferEmitTest.cpp b/MobileGL/MG_Test/Pipe/FramebufferEmitTest.cpp index 7bf4036d..4a0584ca 100644 --- a/MobileGL/MG_Test/Pipe/FramebufferEmitTest.cpp +++ b/MobileGL/MG_Test/Pipe/FramebufferEmitTest.cpp @@ -39,13 +39,19 @@ #include #include +#include +#include #include #include #if defined(_WIN32) #include +#define MGTEST_HAVE_FORK 0 #else +#include +#include #include +#define MGTEST_HAVE_FORK 1 #endif #include "Includes.h" @@ -68,6 +74,113 @@ namespace { return static_cast(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 + 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 + 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(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(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(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(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(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 diff --git a/MobileGL/MG_Test/Pipe/ImageEmitTest.cpp b/MobileGL/MG_Test/Pipe/ImageEmitTest.cpp index 81d432a4..ce8747fc 100644 --- a/MobileGL/MG_Test/Pipe/ImageEmitTest.cpp +++ b/MobileGL/MG_Test/Pipe/ImageEmitTest.cpp @@ -30,13 +30,19 @@ #include #include +#include +#include #include #include #if defined(_WIN32) #include +#define MGTEST_HAVE_FORK 0 #else +#include +#include #include +#define MGTEST_HAVE_FORK 1 #endif #include "Includes.h" @@ -59,6 +65,92 @@ namespace { return static_cast(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 + 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 + 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 = diff --git a/MobileGL/MG_Test/Pipe/ProgramEmitTest.cpp b/MobileGL/MG_Test/Pipe/ProgramEmitTest.cpp index 5ecaa7d8..cce1a5da 100644 --- a/MobileGL/MG_Test/Pipe/ProgramEmitTest.cpp +++ b/MobileGL/MG_Test/Pipe/ProgramEmitTest.cpp @@ -28,13 +28,19 @@ #include #include +#include +#include #include #include #if defined(_WIN32) #include +#define MGTEST_HAVE_FORK 0 #else +#include +#include #include +#define MGTEST_HAVE_FORK 1 #endif #include "Includes.h" @@ -42,6 +48,10 @@ #if MOBILEGL_PIPE_PUSH #include #include +// 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 #endif using namespace MobileGL; @@ -57,6 +67,92 @@ namespace { return static_cast(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 + 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 + 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(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(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 = diff --git a/MobileGL/MG_Test/Pipe/ResourceEmitTest.cpp b/MobileGL/MG_Test/Pipe/ResourceEmitTest.cpp index b9ec6dd9..68245677 100644 --- a/MobileGL/MG_Test/Pipe/ResourceEmitTest.cpp +++ b/MobileGL/MG_Test/Pipe/ResourceEmitTest.cpp @@ -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(target); + return desc; + } + + MGPHandleOnly KindHandle(MGPipeHandle res, MGPipeKind kind) { + return MGPHandleOnly{res, static_cast(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(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(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 diff --git a/MobileGL/MG_Test/Pipe/SamplerEmitTest.cpp b/MobileGL/MG_Test/Pipe/SamplerEmitTest.cpp index c71e4966..279c2dad 100644 --- a/MobileGL/MG_Test/Pipe/SamplerEmitTest.cpp +++ b/MobileGL/MG_Test/Pipe/SamplerEmitTest.cpp @@ -35,13 +35,19 @@ #include #include +#include +#include #include #include #if defined(_WIN32) #include +#define MGTEST_HAVE_FORK 0 #else +#include +#include #include +#define MGTEST_HAVE_FORK 1 #endif #include "Includes.h" @@ -64,6 +70,92 @@ namespace { return static_cast(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 + 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 + 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(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(MGPipeKind::SamplerCso), 0}; + } + MGPHandleOnly ViewHandle(MGPipeHandle cso) { + return MGPHandleOnly{cso, static_cast(MGPipeKind::SamplerViewCso), 0}; + } + + MGPResourceDesc TextureDesc(MGPipeHandle res, Uint32 glName) { + MGPResourceDesc desc{}; + desc.Resource = res; + desc.Target = static_cast(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 = diff --git a/MobileGL/MG_Test/Pipe/TextureEmitTest.cpp b/MobileGL/MG_Test/Pipe/TextureEmitTest.cpp index baa58b79..fba27001 100644 --- a/MobileGL/MG_Test/Pipe/TextureEmitTest.cpp +++ b/MobileGL/MG_Test/Pipe/TextureEmitTest.cpp @@ -39,13 +39,19 @@ #include #include +#include +#include #include #include #if defined(_WIN32) #include +#define MGTEST_HAVE_FORK 0 #else +#include +#include #include +#define MGTEST_HAVE_FORK 1 #endif #include "Includes.h" @@ -68,6 +74,92 @@ namespace { return static_cast(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 + 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 + 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(MGPipeResourceTarget::Tex2D); + + MGPResourceDesc TextureDesc(MGPipeHandle res, Uint32 width, Uint32 glName) { + MGPResourceDesc desc{}; + desc.Resource = res; + desc.Target = static_cast(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(y) * 64 + static_cast(x) * 4; + region.SrcRowStride = 256; + region.SrcSliceStride = 0; + return region; + } + + const MGPipeResourceRecord& TextureRecordOf(Uint32 slot) { + EXPECT_GT(MGPipeApplier().TextureResources.size(), static_cast(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", + [¬hing, &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 =