diff --git a/MobileGL/MG_Test/Wire/ServerLoopTest.cpp b/MobileGL/MG_Test/Wire/ServerLoopTest.cpp index 455afa7c..b7272931 100644 --- a/MobileGL/MG_Test/Wire/ServerLoopTest.cpp +++ b/MobileGL/MG_Test/Wire/ServerLoopTest.cpp @@ -32,20 +32,27 @@ #include #include #include +#include #include #include #include +#include #include +#include #include +#include #include #include #include #include #include #include +#include #include #include #include +#include +#include #include #include @@ -53,11 +60,13 @@ #include +#include #include #include #include #include #include +#include #include #include #include @@ -68,6 +77,9 @@ #else #include #endif +#if defined(__linux__) || defined(__ANDROID__) +#include +#endif using namespace MobileGL; @@ -262,14 +274,39 @@ TEST(ServerLoopTest, AControlRequestRunsOnTheApplyThreadAndUnparksIt) { std::thread::id ranOn{}; Bool onApplyThread = false; } probe; - const MobileGLResult rc = loop.RunOnApplyThread( - +[](void* user) -> MobileGLResult { - auto* p = static_cast(user); - p->ranOn = std::this_thread::get_id(); - p->onApplyThread = Server::ServerLoop::OnApplyThread(); - return MOBILEGL_OK; - }, - &probe); + // POSTED FROM A HELPER THREAD AND WAITED FOR WITH A DEADLINE (review v2 N-9). A park predicate + // that lost the control flag never un-parks the apply thread and the post blocks for ever; the + // case has to SAY that, in its own words, rather than hit ctest's timeout - a timeout is what an + // unrelated hang produces too. Stop() afterwards is what frees the helper: m_stopRequested is in + // the predicate, the thread exits, and its exit block answers the still-posted request + // NOT_INITIALIZED (C2's block), so the join below cannot wedge either. + MobileGLResult rc = MOBILEGL_ERR_INVALID_ARGUMENT; + std::atomic answered{false}; + std::thread poster([&] { + rc = loop.RunOnApplyThread( + +[](void* user) -> MobileGLResult { + auto* p = static_cast(user); + p->ranOn = std::this_thread::get_id(); + p->onApplyThread = Server::ServerLoop::OnApplyThread(); + return MOBILEGL_OK; + }, + &probe); + answered.store(true, std::memory_order_release); + }); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (!answered.load(std::memory_order_acquire) && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + if (!answered.load(std::memory_order_acquire)) { + ADD_FAILURE() << "a posted control request did not un-park the apply thread within 5 s: the park " + "predicate no longer carries the control flag, so a Notify alone cannot break a " + "kWaitForever park (Doorbell::Wait consumes it, re-tests a condition nothing " + "published, and parks again)"; + fixture.Stop(); + poster.join(); + return; + } + poster.join(); EXPECT_EQ(rc, MOBILEGL_OK); EXPECT_NE(probe.ranOn, std::this_thread::get_id()) @@ -366,13 +403,13 @@ TEST(ServerLoopTest, TheAffinityStringResolvesToAMaskAndTheMaskIsLogged) { MG_Config::Ipc.ServerAffinity = saved; } -// M-4 / codex 11, the other half: an EXPLICIT mask must resolve to itself and be LOGGED as the -// EFFECTIVE mask the kernel took, not the requested one. `off` -> 0 and `0x3` -> 0x3 are two -// answers that must DIFFER, so the resolver cannot be a constant; and reading the mask back with -// sched_getaffinity is what makes a cpuset that trimmed the request visible (codex 11), which the -// requested-mask log hid. Red once by making ApplyAffinity return `requested`: the effective read -// is what a trimmed request diverges from, and the explicit-mask assert is what catches a broken -// resolver. +// M-4, the explicit-mask half: an EXPLICIT mask the box can honour must resolve to itself and be +// LOGGED. `off` -> 0 and `0x3` -> 0x3 are two answers that must DIFFER, so the resolver cannot be a +// constant. This case does NOT gate codex 11's effective-mask read-back - on an unrestricted box the +// request and the effective set are the same and `return requested` stays green here (review v2 +// item 6, which struck the claim the first version of this comment made); the read-back's own gate +// is TheResolvedAffinityMaskIsTheKernelsEffectiveSetNotTheRequest below. Red once by making the +// resolver return a constant 0: the explicit-mask assert reads 0. TEST(ServerLoopTest, TheExplicitAffinityMaskResolvesToItselfAndIsLogged) { #if defined(__linux__) || defined(__ANDROID__) if (std::thread::hardware_concurrency() < 2) { @@ -454,7 +491,9 @@ TEST(ServerLoopTest, TheSessionWatermarkAndTheDecoderTallyAgreeAfterEveryRecord) EXPECT_EQ(fixture.session->Consumer().AppliedSeq(), static_cast(i + 1)); EXPECT_EQ(fixture.session->Applier().DecoderAppliedSeq(), static_cast(i + 1)); } - EXPECT_EQ(Server::ServerLoopInstance().DrainedRecords(), 8u); + EXPECT_EQ(Server::ServerLoopInstance().DrainedRecords(), 8u) + << "the loop's own record tally (DrainedRecords) did not move with the eight records it " + "applied, so the ordering-defect control has nothing of its own to hold against appliedSeq"; // retiredSeq is the watermark w1's SEG_STAGE allocator reclaims against; a loop that // applies and never retires ends the first MOBILEGL_IPC_STAGE_MB in Fatal{RingOverrun}. @@ -821,11 +860,11 @@ TEST(ServerLoopTest, TheServerFormatCapsAccessorReadsTheServersOwnBackendNotTheG MG_Backend::pActiveBackendObject.reset(); } -// C7 / ID-54: the make-current decision. A new tuple binds; an identical repeat is a no-op; a -// release request (the three NO_* markers) is recorded, not forwarded. The native-bind COUNT is a -// joint-lane control (it needs a real EGL context); this is the pure decision, which is what -// keeps "the context is bound once and held for life" from being a per-call storm. Red once by -// deleting the RepeatNoOp arm - an identical repeat then classifies as NativeBind. +// C7 / ID-54: the make-current DECISION, pure. A new tuple binds; an identical repeat is a no-op; a +// release request (the three NO_* markers) is recorded, not forwarded. The native-bind COUNT - what +// the driver actually saw - is measured by ServerLoopEglTest's C7 control on a real context below, +// which is the control round 2 said needed the joint lane. Red once by deleting the RepeatNoOp arm: +// an identical repeat then classifies as NativeBind. TEST(ServerLoopTest, MakeCurrentClassifiesRepeatAndReleaseWithoutRebinding) { const EGLDisplay dpy = reinterpret_cast(0x1); const EGLSurface draw = reinterpret_cast(0x2); @@ -851,6 +890,724 @@ TEST(ServerLoopTest, MakeCurrentClassifiesRepeatAndReleaseWithoutRebinding) { Server::EglBindAction::ClientRelease); } +// ===================================================================================== +// Round 3: the gates the v2 review found missing (N-5), each driving the PRODUCTION wiring +// ===================================================================================== + +// M-6 (review v2 item 7): the fix is the ServerLoop::Stop() call INSIDE ShutdownSplitRoles, so the +// gate calls ShutdownSplitRoles - not Stop - after the shape M-6 names: InitSplitRoles step 1 built +// the server's private backend and step 3 (ClientSession::Start) failed EARLY. The failure is a real +// one, not a skipped step: P5's client refuses every transport but InProcess by name, before Accept +// and before any ring exists. Red once by deleting Init.cpp's `ServerLoopInstance().Stop()` line: +// the backend outlives the shutdown and the next CreateBackend is refused. +TEST(ServerLoopTest, ShutdownSplitRolesAfterAnEarlyStartFailureDropsTheServersPrivateBackend) { + Server::ServerLoop& loop = Server::ServerLoopInstance(); + ASSERT_EQ(loop.CreateBackend(BackendType::DirectGLES), MOBILEGL_OK); + ASSERT_NE(MobileGL::MG_Remote::Client::ClientSessionInstance().Start(MG_Config::TransportMode::Spawn, ""), + MOBILEGL_OK) + << "the forced early Start failure did not fail; the case has nothing to shut down after"; + ASSERT_NE(loop.Backend(), nullptr); + ASSERT_FALSE(loop.Running()); + + MG_Backend::ShutdownSplitRoles(); + + EXPECT_EQ(loop.Backend(), nullptr) + << "ShutdownSplitRoles left the server's private backend alive after an early " + "ClientSession::Start failure (M-6): every later split bring-up in this process is refused " + "at CreateBackend's m_backend guard"; + EXPECT_FALSE(loop.Running()); + EXPECT_EQ(loop.CreateBackend(BackendType::DirectGLES), MOBILEGL_OK) + << "the leaked backend blocks the next split bring-up"; + loop.Stop(); +} + +// codex 11 (review v2 item 6): the resolved mask must be the EFFECTIVE set the kernel took, read +// back with sched_getaffinity, not the request. The review's point was that on an unrestricted box +// the two never differ, so `return requested` stays green; this makes them differ without a cpuset: +// the request names one cpu this thread may use (taken from the kernel's own answer, so the case +// constructs nothing it observes) PLUS cpu 63, which does not exist on any box with fewer than 64 +// cpus. sched_setaffinity accepts the mask with the absent cpu dropped, so the effective set is the +// one real cpu - and a resolver that echoes the request reports a cpu the thread can never run on. +// Red once by making ApplyAffinity `return requested;`: the resolved mask reads 0x8000000000000001- +// shaped instead of 0x1-shaped, and the log line names the wrong mask. +#if defined(__linux__) || defined(__ANDROID__) +TEST(ServerLoopTest, TheResolvedAffinityMaskIsTheKernelsEffectiveSetNotTheRequest) { + const unsigned hw = std::thread::hardware_concurrency(); + if (hw == 0 || hw >= 64) { + GTEST_SKIP() << "needs a box with fewer than 64 cpus so that cpu 63 is absent (has " << hw << ")"; + } + cpu_set_t allowed; + CPU_ZERO(&allowed); + ASSERT_EQ(sched_getaffinity(0, sizeof(allowed), &allowed), 0); + int usable = -1; + for (int cpu = 0; cpu < 63; ++cpu) { + if (CPU_ISSET(cpu, &allowed)) { + usable = cpu; + break; + } + } + if (usable < 0 || CPU_ISSET(63, &allowed)) { + GTEST_SKIP() << "this thread's allowed set is not the shape the case needs"; + } + const Uint64 requested = (1ull << usable) | (1ull << 63); + const Uint64 effective = 1ull << usable; + char requestedText[40]; + std::snprintf(requestedText, sizeof(requestedText), "0x%llx", static_cast(requested)); + char effectiveLine[64]; + std::snprintf(effectiveLine, sizeof(effectiveLine), "RESOLVED mask 0x%llx (0 = no affinity applied)", + static_cast(effective)); + + const String saved = MG_Config::Ipc.ServerAffinity; + MG_Config::Ipc.ServerAffinity = requestedText; + + ServerFixture fixture; + ASSERT_TRUE(fixture.Handshake()); + ASSERT_TRUE(fixture.StartLoop()); + ASSERT_TRUE(fixture.WaitUntilTrulyParked()); + + EXPECT_EQ(Server::ServerLoopInstance().ResolvedAffinityMask(), effective) + << "the resolved affinity mask reports the REQUESTED set (" << requestedText + << ") rather than the effective one the kernel took: cpu 63 does not exist on this " << hw + << "-cpu box, sched_setaffinity dropped it, and codex 11's sched_getaffinity read-back is what " + "makes that visible"; + const std::string log = ReadLog(); + EXPECT_NE(log.find(effectiveLine), std::string::npos) + << "the logged RESOLVED mask is not the effective one (" << effectiveLine << ")"; + + fixture.Stop(); + MG_Config::Ipc.ServerAffinity = saved; +} +#endif + +namespace { + // A second DirectGLES object whose format cache the case can FILL, so that the server's (empty, + // never InitCapabilities'd) cache and the global's answer differently. Filling an INPUT is not + // constructing the observed state (R-16): what is observed is which object each read follows. + struct CapsProbeBackend final : MG_Backend::DirectGLES::BackendObject_DirectGLES { + MG_Backend::FormatCapabilityCache& Caps() { return MutableFormatCapabilities(); } + }; +} // namespace + +// C6 (review v2 item 9): the seven table-3 reads, asserted by their ANSWERS with two objects +// installed - not by the accessor's address, which is what round 2 gated and what reverting one +// call site at a time never moved. The server's cache is empty; the global's is not. +// +// Four of the seven are VALUE reads and the two objects' caches disagree for them: the +// HasCachedFormatCapability pair behind the caveat decision (Utils.cpp) and the +// ClampSamplesToBackendSupport pair (BackendObject_DirectGLES.cpp). Two are NULL-CHECK reads - +// GenerateFormatInfo's and BackendFormatAddsAlpha's "is there a backend at all" - which two live +// objects cannot tell apart, so for those the global is NULL and the server is present, which is +// exactly the window between InitSplitRoles step 1 (the server backend) and step 4 (the client +// object) that the reads used to answer "no backend" in. The seventh, UsesWidenedPacked16NormStorage's +// gate in front of a memoised driver probe, is a null check whose two arms give the same answer on +// the reference rasterizer (the probe says "not mirrored", the no-backend arm says false); it has +// no answer-level control on this box and the report says so. +// +// Red once, one site at a time: revert the HasCachedFormatCapability pair -> the caveat decision +// follows the global; revert ClampSamples -> the clamp answers 6; revert GenerateFormatInfo's null +// check -> RGB8 widens to RGBA8; revert BackendFormatAddsAlpha's -> adds-alpha answers true. +TEST(ServerLoopTest, TheSevenFormatCapabilityReadsAnswerFromTheServersBackendNotTheGlobal) { + namespace TextureImpl = MG_Backend::DirectGLES::TextureImpl; + Server::ServerLoop& loop = Server::ServerLoopInstance(); + ASSERT_EQ(loop.CreateBackend(BackendType::DirectGLES), MOBILEGL_OK); + const SizeT tex2d = MG_Backend::GetFormatCapabilityTargetIndex(TextureTarget::Texture2D); + const SizeT rb = MG_Backend::GetRenderbufferFormatCapabilityTargetIndex(); + const SizeT rgb8 = static_cast(TextureInternalFormat::RGB8); + const SizeT rgba8 = static_cast(TextureInternalFormat::RGBA8); + + // (A) the global holds a DIFFERENT object whose cache says the opposite of "empty": RGB8 is + // caveat-only there, and RGBA8 was "probed" at six samples - a count no driver's own maximum + // is, so it cannot collide with the fallback the server's empty cache falls to. + auto client = MakeUnique(); + for (const SizeT target : {tex2d, rb}) { + client->Caps().CaveatCaps[target][rgb8] |= MG_Backend::FormatCapability::Creatable; + client->Caps().CaveatCaps[target][rgb8] |= MG_Backend::FormatCapability::FramebufferRenderable; + client->Caps().SampleCounts[target][rgba8] = {6, 2}; + } + MG_Backend::pActiveBackendObject = std::move(client); + + EXPECT_FALSE(TextureImpl::ShouldUseCaveatTextureFormat(TextureInternalFormat::RGB8, TextureTarget::Texture2D)) + << "the caveat decision followed the global's cache (caveat-only RGB8), not the server's " + "(empty): the HasCachedFormatCapability pair reads pActiveBackendObject again"; + EXPECT_FALSE(TextureImpl::ShouldUseCaveatRenderbufferFormat(TextureInternalFormat::RGB8)) + << "the renderbuffer caveat decision followed the global's cache, not the server's"; + EXPECT_NE(MG_Backend::DirectGLES::ClampSamplesToBackendSupport(tex2d, TextureInternalFormat::RGBA8, GL_RGBA8, 8), 6) + << "the sample clamp answered from the global's probed counts (6), not the server's"; + EXPECT_NE(MG_Backend::DirectGLES::ClampSamplesToBackendSupport(rb, TextureInternalFormat::RGBA8, GL_RGBA8, 8), 6) + << "the renderbuffer sample clamp answered from the global's probed counts (6), not the server's"; + + // (B) the null-check reads: global NULL, server present. + MG_Backend::pActiveBackendObject.reset(); + GLenum internalFormat = 0; + GLenum format = 0; + GLenum type = 0; + TextureImpl::GenerateTextureFormatInfo(TextureInternalFormat::RGB8, &internalFormat, &format, &type, + TextureTarget::Texture2D); + EXPECT_EQ(internalFormat, static_cast(GL_RGB8)) + << "the format info followed the global (null, so the fallback normalisation widened RGB8) " + "instead of the server's backend (present, so native RGB8); got 0x" << std::hex << internalFormat; + EXPECT_FALSE(TextureImpl::BackendTextureFormatAddsAlpha(TextureInternalFormat::RGB8, TextureTarget::Texture2D)) + << "the adds-alpha answer followed the global (null, so the fallback adds alpha) instead of " + "the server's backend"; + EXPECT_FALSE(TextureImpl::BackendRenderbufferFormatAddsAlpha(TextureInternalFormat::RGB8)) + << "the renderbuffer adds-alpha answer followed the global (null) instead of the server's backend"; + + loop.Stop(); +} + +// ===================================================================================== +// The EGL fixture: the SERVER's real DirectGLES backend, a real headless context (mesa +// surfaceless, llvmpipe) on the apply thread, driven only through v1's twelve forwarders +// ===================================================================================== +// +// Round 2 shipped its C7, M-3 and m-5 claims with the caveat "the native count is a joint-lane +// control (needs a real EGL context)", and the review answered that nothing then gates them. This +// fixture is the answer to that: the server role's own backend, brought up exactly the way +// InitSplitRoles and the client's EGL virtuals bring it up - CreateBackend on the app thread, then +// InitializeEGLDisplay / CreateEGLPbufferSurface / MakeEGLCurrent as BLOCKING control requests on +// mgl-srv-apply - with no client object, no emit table and no scenario. What the joint lane adds is +// c1's client; what it does not add is anything these cases observe: the driver's own eglMakeCurrent +// count, the applier's read_pixels reply, and the backend's buffer twins. +// +// THE NATIVE COUNT IS READ WHERE THE DRIVER READS IT. DirectGLES dispatches every EGL call through +// its function table (g_EGLFuncs, filled by BackendObject_DirectGLES::Initialize); the fixture puts +// a counting trampoline in front of that table's eglMakeCurrent and forwards to the driver's entry +// point. Nothing in the production tree is edited or told, and no production counter is trusted for +// this number - ServerLoop::NativeBindCount() is the forwarder's view, and the whole point of the C7 +// control is that the two can disagree (they did: 2 native binds per process with the forwarder +// counting 1, review v2 item 10). +// +// NO USABLE EGL IS A SKIP, UNLESS MOBILEGL_ITEST_REQUIRE_GPU SAYS OTHERWISE - the integration +// harness's own rule (ScenarioFixture.h): a developer box without mesa should not fail a run it could +// not perform, but a lane that relies on these cases sets the variable, and then an unusable EGL is +// a FAILURE naming the step, never a green that ran nothing (review v2 N-1's lesson). +#if !defined(_WIN32) +namespace { + + std::atomic g_nativeEglBinds{0}; + std::atomic g_nativeEglReleases{0}; + decltype(MG_Backend::DirectGLES::g_EGLFuncs.eglMakeCurrent) g_driverEglMakeCurrent = nullptr; + + EGLBoolean CountingEglMakeCurrent(EGLDisplay dpy, EGLSurface draw, EGLSurface read, EGLContext ctx) { + if (ctx == EGL_NO_CONTEXT) { + g_nativeEglReleases.fetch_add(1, std::memory_order_acq_rel); + } else { + g_nativeEglBinds.fetch_add(1, std::memory_order_acq_rel); + } + return g_driverEglMakeCurrent(dpy, draw, read, ctx); + } + + Bool RequireGpuFromEnvironment() { + const char* value = std::getenv("MOBILEGL_ITEST_REQUIRE_GPU"); + return value != nullptr && value[0] != '\0' && std::strcmp(value, "0") != 0; + } + + // The integration harness's headless pin (HeadlessGL.cpp, EnsureHeadlessPlatform), for the same + // reason: mesa's surfaceless platform needs no display, and glvnd has to be told which vendor to + // load. The vendor json is the one MG_IntegrationTest/CMakeLists.txt pins by default; an operator + // who set __EGL_VENDOR_LIBRARY_FILENAMES keeps theirs. + void PinHeadlessEglEnvironment() { + if (std::getenv("EGL_PLATFORM") == nullptr) setenv("EGL_PLATFORM", "surfaceless", 1); + unsetenv("DISPLAY"); + unsetenv("WAYLAND_DISPLAY"); + if (std::getenv("__EGL_VENDOR_LIBRARY_FILENAMES") == nullptr) { + const char* mesa = "/usr/share/glvnd/egl_vendor.d/50_mesa.json"; + std::error_code ec; + if (std::filesystem::exists(mesa, ec)) setenv("__EGL_VENDOR_LIBRARY_FILENAMES", mesa, 1); + } + } + + // A control request that runs an arbitrary callable on the apply thread. A std::function is + // fine in a test; production's ControlWork is a raw pointer for the teardown path's sake. + MobileGLResult OnApply(const std::function& body) { + std::function copy = body; + return Server::ServerLoopInstance().RunOnApplyThread( + +[](void* user) -> MobileGLResult { + (*static_cast*>(user))(); + return MOBILEGL_OK; + }, + ©); + } + + struct EglServerFixture : ServerFixture { + // The client's VIRTUAL handles. EGLImpl mints small integers, and every one of them is 0x1 + // on this host (the N-3 finding's whole point), so the cases use exactly that shape. + static EGLDisplay Dpy() { return reinterpret_cast(static_cast(0x1)); } + static EGLSurface Surf() { return reinterpret_cast(static_cast(0x1)); } + static EGLContext Ctx() { return reinterpret_cast(static_cast(0x1)); } + + // The backend's draw-shaped paths (ReadPixels syncs the bound textures and the read + // framebuffer) walk the process's frontend GLContext, which a bare unit process does not + // have; installed for the case's life and put back afterwards (SplitBufferTest's shape). + UniquePtr savedContext; + + // Empty on success, else the step that failed - the harness's SkipReason shape. + std::string BringUp() { + PinHeadlessEglEnvironment(); + savedContext = Move(MG_State::pGLContext); + MG_State::pGLContext = MakeUnique(); + Server::ServerLoop& loop = Server::ServerLoopInstance(); + // InitSplitRoles step 1: the server's private backend, no GL and no EGL yet; its + // Initialize() loads the driver's EGL/GLES entry points into the two tables. + if (loop.CreateBackend(BackendType::DirectGLES) != MOBILEGL_OK) return "CreateBackend refused"; + const MG_External::EGLFunctionsTable& egl = MG_Backend::DirectGLES::g_EGLFuncs; + if (egl.eglMakeCurrent == nullptr) return "libEGL did not load (no eglMakeCurrent entry point)"; + if (egl.eglMakeCurrent != &CountingEglMakeCurrent) g_driverEglMakeCurrent = egl.eglMakeCurrent; + g_nativeEglBinds.store(0); + g_nativeEglReleases.store(0); + MG_External::EGLFunctionsTable counted = egl; + counted.eglMakeCurrent = &CountingEglMakeCurrent; + MG_Backend::DirectGLES::SetEGLFuncsTable(counted); + if (!Handshake()) return "the session handshake"; + if (!StartLoop()) return "ServerLoop::Start"; + EGLint major = 0; + EGLint minor = 0; + if (!Server::ServerInitializeEGLDisplay(Dpy(), &major, &minor)) return "ServerInitializeEGLDisplay"; + // THE SURFACE'S OWN CREATION is what binds natively (InitPbufferSurface -> MakeCurrent). + if (!Server::ServerCreateEGLPbufferSurface(Surf(), 64, 64)) { + return "ServerCreateEGLPbufferSurface - no usable headless EGL (EGL_PLATFORM=surfaceless, " + "__EGL_VENDOR_LIBRARY_FILENAMES -> mesa/llvmpipe) on this box"; + } + return {}; + } + + Bool MakeCurrent() { return Server::ServerMakeEGLCurrent(Dpy(), Surf(), Surf(), Ctx()); } + Bool ReleaseCurrent() { + return Server::ServerMakeEGLCurrent(Dpy(), EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); + } + + void TearDown() { + Server::ServerReleaseEGLResources(); + Stop(); + MG_State::pGLContext = Move(savedContext); + } + }; + +// FAIL() and GTEST_SKIP() both return from the enclosing void test body. +#define MGL_EGL_BRING_UP_OR_BAIL(fixture) \ + do { \ + const std::string why = (fixture).BringUp(); \ + if (!why.empty()) { \ + (fixture).Stop(); \ + MG_State::pGLContext = Move((fixture).savedContext); \ + if (RequireGpuFromEnvironment()) { \ + FAIL() << "MOBILEGL_ITEST_REQUIRE_GPU is set, so the server's EGL bring-up failing is " \ + "a failure, not a skip. It failed at: " \ + << why; \ + } \ + GTEST_SKIP() << "no usable headless EGL for the server backend: " << why; \ + } \ + } while (0) + + // The production shape of a buffer under split, step by step through the applier's own entry + // points and the REAL op table (Initialize registered it): resource_create mints the record, + // resource_respecify stores the descriptor - its bytes never cross under split (table 1 row 19), + // they arrive as resource_subdata right behind it - and resource_subdata stages bytes into the + // server shadow, minting the twin if no draw has (ID-52 item 3). `definedContent` is the + // descriptor's HasDefinedContent: what glBufferData(size, data) sets and glBufferData(size, NULL) + // clears. + MG_Pipe::MGPResourceDesc BufferDesc(Uint32 slot, Uint32 width, Bool definedContent) { + MG_Pipe::MGPResourceDesc desc{}; + desc.Resource = MG_Pipe::MGPipeHandle{slot, 1}; + desc.Target = MG_Pipe::kMGPipeResourceTargetBuffer; + desc.Width = width; + desc.Height = 1; + desc.Depth = 1; + desc.ArrayLayers = 1; + desc.Levels = 1; + desc.Samples = 1; + desc.Usage = static_cast(BufferUsage::StaticDraw); + desc.HasDefinedContent = definedContent ? 1 : 0; + return desc; + } + + MG_Pipe::MGPipeHandle DeclareBuffer(const MG_Pipe::MGPResourceDesc& desc) { + EXPECT_TRUE(MG_Pipe::MGPipeApplyResourceCreate(desc)) << "resource_create refused slot " << desc.Resource.Slot; + EXPECT_TRUE(MG_Pipe::MGPipeApplyResourceRespecify(desc, nullptr)) << "resource_respecify refused"; + return desc.Resource; + } + + void StageBytes(MG_Pipe::MGPipeHandle res, SizeT offset, SizeT size, Uint8 fill) { + const MG_Pipe::MGPipeResourceOps* ops = MG_Pipe::MGPipeGetResourceOps(); + ASSERT_NE(ops, nullptr) << "no resource op table is registered"; + ASSERT_NE(ops->SubData, nullptr); + Vector bytes(size, fill); + MG_Pipe::MGPSubData rec{}; + rec.Res = res; + rec.Target = MG_Pipe::MGPipePackSubDataTarget(MG_Pipe::kMGPipeResourceTargetBuffer, 0u); + ASSERT_TRUE(MG_Pipe::MGPipeSetSubDataBufferRange(rec, offset, size)); + ops->SubData(res, rec, bytes.data()); + } + + MG_Backend::DirectGLES::BufferImpl::GLESBufferResource* Ensure(MG_Pipe::MGPipeHandle res) { + return MG_Backend::DirectGLES::BufferImpl::EnsureBufferResourceForHandle({}, res); + } + + MG_Backend::DirectGLES::BufferImpl::GLESBufferResource* Twin(MG_Pipe::MGPipeHandle res) { + return MG_Backend::DirectGLES::BufferImpl::FindBufferResourceForHandle(res); + } + +} // namespace + +// C7 / ID-54, THE NATIVE HALF, measured where the review said it was not: at the driver. Surface +// creation binds the context natively on the apply thread (that is bind #1 of the "2 per process" +// the review counted); the client's first eglMakeCurrent onto that surface used to be bind #2 and is +// now deduped by BackendObject_DirectGLES's ID-54 arm; an identical repeat is a forwarder-level +// RepeatNoOp; a client release-current is recorded and never reaches the driver; a bind after the +// release is a RepeatNoOp again because the driver never lost the context. ONE native bind, ZERO +// native releases, and the apply thread is still the owner at the end. Red once, three ways: (a) make +// ApplyMakeCurrent ignore the classification (the repeat is forwarded and the release reaches the +// driver), (b) make the backend's native call unconditional again (2 native binds), (c) forward the +// ClientRelease arm to the backend (1 native release). +TEST(ServerLoopEglTest, MakeCurrentBindsNativelyOncePerContextAndNeverForwardsAClientRelease) { + EglServerFixture fixture; + MGL_EGL_BRING_UP_OR_BAIL(fixture); + Server::ServerLoop& loop = Server::ServerLoopInstance(); + + ASSERT_EQ(g_nativeEglBinds.load(), 1) << "the surface's own creation did not bind natively exactly once"; + ASSERT_EQ(loop.NativeBindCount(), 0u); + + ASSERT_TRUE(fixture.MakeCurrent()); + EXPECT_EQ(g_nativeEglBinds.load(), 1) + << "the client's first make-current cost a second native eglMakeCurrent on a surface its own " + "creation had already made current on this thread (ID-54: bind once per tuple)"; + EXPECT_EQ(loop.NativeBindCount(), 1u) << "the first make-current was not forwarded at all"; + + ASSERT_TRUE(fixture.MakeCurrent()); + EXPECT_EQ(loop.NativeBindCount(), 1u) + << "an identical repeat make-current was forwarded as a native bind; the owner slot would be " + "written again and the caches invalidated for nothing"; + EXPECT_EQ(g_nativeEglBinds.load(), 1); + + ASSERT_TRUE(fixture.ReleaseCurrent()); + EXPECT_EQ(loop.ClientReleaseCount(), 1u) << "the client release-current was not recorded"; + EXPECT_EQ(g_nativeEglReleases.load(), 0) + << "a client release-current reached the driver as eglMakeCurrent(NO_CONTEXT): the apply thread " + "lost the context it holds for life (ID-54: never unbind on a client release)"; + + ASSERT_TRUE(fixture.MakeCurrent()); + EXPECT_EQ(loop.NativeBindCount(), 1u) << "a bind after the recorded, unforwarded release was forwarded again"; + EXPECT_EQ(g_nativeEglBinds.load(), 1); + + // The owner is still the apply thread by EGL ground truth (IsBackendContextCurrentOnThisThread + // re-verifies against eglGetCurrentContext), and not this thread. + Bool ownerIsApplyThread = false; + ASSERT_EQ(OnApply([&] { ownerIsApplyThread = MG_Backend::DirectGLES::IsBackendContextCurrentOnThisThread(); }), + MOBILEGL_OK); + EXPECT_TRUE(ownerIsApplyThread) << "after the sequence the apply thread no longer owns the context"; + EXPECT_FALSE(MG_Backend::DirectGLES::IsBackendContextCurrentOnThisThread()) << "the app thread owns the context"; + + fixture.TearDown(); +} + +// N-3: a destroy-recreate with the SAME handle values must be a real bind again. Before the fix the +// tuple survived ServerReleaseEGLResources, the recreated context's first make-current classified as +// a RepeatNoOp, nothing ran in the base class (no InitCapabilities, no current-thread record) and no +// caps were republished - a silent no-context-current from the forwarder's point of view, visible +// only as the next swap failing. Red once by deleting the ForgetCurrentTuple() calls the forwarders +// make: NativeBindCount() stays 1 and the swap after the re-create fails. +TEST(ServerLoopEglTest, ADestroyedContextForgetsTheTupleSoTheSameHandleValuesBindAgain) { + EglServerFixture fixture; + MGL_EGL_BRING_UP_OR_BAIL(fixture); + Server::ServerLoop& loop = Server::ServerLoopInstance(); + ASSERT_TRUE(fixture.MakeCurrent()); + ASSERT_EQ(loop.NativeBindCount(), 1u); + ASSERT_TRUE(Server::ServerSwapEGLBuffers(EglServerFixture::Dpy(), EglServerFixture::Surf())) + << "the swap BEFORE the destroy failed, so the case could not tell lost bookkeeping from a swap " + "that never worked"; + ASSERT_EQ(g_nativeEglBinds.load(), 1); + + // The context goes (DestroyEGLContext on the apply thread) and comes back under the same + // handle values. + Server::ServerReleaseEGLResources(); + EGLint major = 0; + EGLint minor = 0; + ASSERT_TRUE(Server::ServerInitializeEGLDisplay(EglServerFixture::Dpy(), &major, &minor)); + ASSERT_TRUE(Server::ServerCreateEGLPbufferSurface(EglServerFixture::Surf(), 64, 64)); + ASSERT_EQ(g_nativeEglBinds.load(), 2) << "the recreated surface did not bind natively"; + + ASSERT_TRUE(fixture.MakeCurrent()); + EXPECT_EQ(loop.NativeBindCount(), 2u) + << "the make-current after a destroy-recreate with the same handle values was classified as a " + "RepeatNoOp: the tuple outlived the context it named (N-3), so no base-class bookkeeping " + "ran and the caps snapshot was not republished"; + EXPECT_TRUE(Server::ServerSwapEGLBuffers(EglServerFixture::Dpy(), EglServerFixture::Surf())) + << "the swap after the re-create failed: BackendObject::SwapEGLBuffers found no current-thread " + "record, because the make-current that should have made one was treated as a repeat"; + EXPECT_EQ(g_nativeEglBinds.load(), 2) << "one native bind per context lifetime, not more"; + + fixture.TearDown(); +} + +// ID-49's tight-size half, gated (review v2 N-5: "nothing, unit and joint"). The client's DstSize is +// deliberately WRONG - 80, the size a ROW_LENGTH=8 / SKIP_* client would compute for a 4x3 RGBA8 +// read whose tight extent is 48 - and the reply must still be the tight 48 bytes: posted at 48, +// counted at 48, read into a scratch that grew to 48 and never to the client's 80, and stamped 48 in +// the slot header the client reads. Red once, three ways: post at info.DstSize, size the scratch from +// info.DstSize, compute `tight` from info.DstSize. +TEST(ServerLoopEglTest, AReadPixelsReplyIsTheTightExtentWhateverDstSizeTheClientSent) { + EglServerFixture fixture; + MGL_EGL_BRING_UP_OR_BAIL(fixture); + ASSERT_TRUE(fixture.MakeCurrent()); + + // THE CLIENT'S HALF OF THE VERB, exactly as glReadPixels runs it before it reaches a backend + // (MGP_FILL(ReadPixels) in MG_Impl): the validate point fills gPipeInputs' residual fields + // from the frontend context - the texture-unit base, the bound framebuffers, the pack state + // - which the server's ReadPixels reads BARRIER-PULLED under R-1 while the client is parked + // in the barrier (PipeInputs.h's class table). Without it the apply thread reads a block + // nobody filled and walks a null texture-unit base; with it the case drives the same two + // halves the inproc lane drives, in the same order, on the same process-wide block. + MG_Pipe::MGPipeValidateForVerb(MG_Pipe::MGPipeVerb::ReadPixels); + + MG_Pipe::MGPReadbackInfo info{}; + info.Res = MG_Pipe::kMGPipeNullHandle; // read_pixels: the bound read surface answers + info.Box = MG_Pipe::MGPBox{0, 0, 0, 4, 3, 1}; + info.Format = 0x1908; // GL_RGBA + info.Type = 0x1401; // GL_UNSIGNED_BYTE + info.DstSize = 80; // wrong on purpose; tight is 4 * 3 * 4 = 48 + const Uint64 seq = fixture.encoder.EncodeRecord(MG_Pipe::MGPWireOp::ReadPixels, &info, sizeof(info)); + ASSERT_NE(seq, Codec::kInvalidSeq); + fixture.encoder.Publish(); + fixture.producer.PublishAndNotify(seq); + ASSERT_EQ(fixture.producer.WaitForApplied(seq, 5000), Transport::SessionWait::Reached); + + const Server::ServerVerbSink& verbs = fixture.session->Applier().Verbs(); + ASSERT_EQ(verbs.Readbacks(), 1u) << "the read_pixels record was declined or errored rather than applied"; + EXPECT_EQ(verbs.ReadbackBytes(), 48u) + << "the reply was posted at the client's DstSize (80), not the tight w*h*bpp extent (48) (ID-49)"; + EXPECT_EQ(verbs.ReadbackScratchBytes(), 48u) + << "the scratch was sized from the client's DstSize rather than the tight extent; a DstSize " + "smaller than tight would then be the heap overflow codex 1 found"; + + // And through the client's own view of the slot: the seq stamped back, OK, 48 bytes. + Transport::ReplySlotPool replies(fixture.clientSegments.ReplyBase(), fixture.clientSegments.ReplyBytes(), + fixture.clientSegments.ReplySlotCount()); + ASSERT_TRUE(replies.Valid()); + Vector pixels(128, 0); + Int32 status = -1; + Uint64 size = 0; + EXPECT_TRUE(replies.Read(seq, pixels.data(), pixels.size(), &status, &size)); + EXPECT_EQ(status, static_cast(Transport::kReplyStatusOk)); + EXPECT_EQ(size, 48u) << "the slot header carries the client's DstSize, not the tight extent"; + + fixture.TearDown(); +} + +// M-3 / codex 4, the POOL-REUSE reader (review v2 item 4: "nothing can notice it going away"). The +// production sequence, on the real op table and the real backend: A - glBufferData(64, data), drawn +// (ensured), then deleted on the apply thread, so its id retires into the buffer pool; two presents +// with a finish between them move the frame-completion watermark past the id's retire serial, which +// is the pool's hand-out rule. B - glBufferData(64, NULL) then glBufferSubData(0, 16), the ordinary +// streaming idiom, so its server shadow covers [0, 16) of 64. B's first ensure finds A's id in the +// pool and would seed the driver's whole 64-byte store from that shadow, 48 bytes of which nothing +// staged. The refusal fires BEFORE the glBufferSubData, so the forked child - whose only thread is a +// copy of this one and holds no context - reaches it with no GL call that matters (a no-context call +// dispatches to a no-op). Red once by deleting the pool-reuse MGL_SERVER_STAGED_REQUIRE: the child +// seeds the store and does not die. +TEST(StagedShadowProductionTest, ASparseShadowForcedThroughAPoolReuseUploadIsFatalByName) { + EglServerFixture fixture; + MGL_EGL_BRING_UP_OR_BAIL(fixture); + ASSERT_TRUE(fixture.MakeCurrent()); + + const MG_Pipe::MGPipeHandle a = DeclareBuffer(BufferDesc(21, 64, true)); + StageBytes(a, 0, 64, 0x11); + Bool aEnsured = false; + ASSERT_EQ(OnApply([&] { + aEnsured = Ensure(a) != nullptr; + MG_Pipe::MGPipeGetResourceOps()->Destroy(a); + for (int frame = 0; frame < 2; ++frame) { + if (MG_Backend::DirectGLES::g_GLESFuncs.glFinish) MG_Backend::DirectGLES::g_GLESFuncs.glFinish(); + MG_Backend::DirectGLES::Present(); + } + }), + MOBILEGL_OK); + ASSERT_TRUE(aEnsured) << "A's ensure minted no storage, so nothing retired into the pool"; + + const MG_Pipe::MGPipeHandle b = DeclareBuffer(BufferDesc(22, 64, false)); + StageBytes(b, 0, 16, 0x22); + ASSERT_NE(Twin(b), nullptr) << "the subdata did not mint B's twin (ID-52 item 3)"; + ASSERT_EQ(Twin(b)->id, 0u) << "B already has a store; the pool-reuse arm cannot be reached"; + + EXPECT_EXIT(Ensure(b), ::testing::KilledBySignal(SIGABRT), ".*"); + const std::string log = ReadLog(); + EXPECT_NE(log.find("Fatal{StageSnapshotTooNarrow, \"pool_reuse_whole_store\"}"), std::string::npos) + << "the abort was not the pool-reuse reader's coverage refusal (M-3); the log says: " << log; + + fixture.TearDown(); +} + +// M-3 / codex 4, the RESPECIFY reader. A - glBufferData(64, data), drawn (ensured: id, storage, +// serial all current). Then glBufferData(64, data2) again, whose bytes do not cross (table 1 row 19): +// the record says "defined content", the old shadow is dropped, the store is pending a respecify - +// and only 16 of the 64 bytes then arrive, staged from the app thread the way the applier queues +// them off the context thread. The next ensure would RespecifyStorageWith(initialData = the sparse +// shadow) over the whole store. The refusal fires before that glBufferData. Red once by deleting the +// respecify MGL_SERVER_STAGED_REQUIRE. +TEST(StagedShadowProductionTest, ASparseShadowForcedThroughAStorageRespecifyIsFatalByName) { + EglServerFixture fixture; + MGL_EGL_BRING_UP_OR_BAIL(fixture); + ASSERT_TRUE(fixture.MakeCurrent()); + + const MG_Pipe::MGPResourceDesc desc = BufferDesc(23, 64, true); + const MG_Pipe::MGPipeHandle res = DeclareBuffer(desc); + StageBytes(res, 0, 64, 0x33); + Bool ensured = false; + ASSERT_EQ(OnApply([&] { ensured = Ensure(res) != nullptr; }), MOBILEGL_OK); + ASSERT_TRUE(ensured); + ASSERT_NE(Twin(res)->id, 0u); + + // Off the context thread on purpose (the apply thread is parked): the respecify takes the + // "cannot touch GL now" arm and leaves the store PENDING, exactly as an emitted record does. + ASSERT_TRUE(MG_Pipe::MGPipeApplyResourceRespecify(desc, nullptr)); + ASSERT_TRUE(Twin(res)->pendingRespecify) << "the respecify did not leave the store pending"; + StageBytes(res, 0, 16, 0x44); + + EXPECT_EXIT(Ensure(res), ::testing::KilledBySignal(SIGABRT), ".*"); + const std::string log = ReadLog(); + EXPECT_NE(log.find("Fatal{StageSnapshotTooNarrow, \"respecify_whole_store\"}"), std::string::npos) + << "the abort was not the respecify reader's coverage refusal (M-3); the log says: " << log; + + fixture.TearDown(); +} + +// M-1 / codex 8, THE COVERAGE HALF (review v2 item 2: "gated nowhere"). glBufferData(64, NULL) + +// glBufferSubData(0, 16) + a draw: the shadow covers [0, 16) and the NULL respecify seeds nothing, so +// that is legal. Then a resource_flush_range over [16, 48) with no bytes (that is what the row +// carries under split, item 12) queues a range NOTHING STAGED, and the next draw-time drain would +// take it through the three-tier ladder - whose tier 1 is a range-invalidating map that declares +// the old bytes dead. RequireStagedCoverageForPendingRanges refuses before the drain, by name. Red +// once by neutering its loop body: the child then dies one tier later, inside UploadRangeFrom's own +// check, with a DIFFERENT site name - which this assertion refuses - or, on the tier-1 map, not at +// all. +TEST(StagedShadowProductionTest, AQueuedRangeOutsideTheStagedCoverageIsFatalAtTheDrainByName) { + EglServerFixture fixture; + MGL_EGL_BRING_UP_OR_BAIL(fixture); + ASSERT_TRUE(fixture.MakeCurrent()); + + const MG_Pipe::MGPipeHandle res = DeclareBuffer(BufferDesc(24, 64, false)); + StageBytes(res, 0, 16, 0x55); + Bool ensured = false; + ASSERT_EQ(OnApply([&] { ensured = Ensure(res) != nullptr; }), MOBILEGL_OK); + ASSERT_TRUE(ensured); + ASSERT_NE(Twin(res)->id, 0u); + ASSERT_FALSE(Twin(res)->pendingRespecify); + + MG_Pipe::MGPFlushRange flush{}; + flush.Res = res; + flush.Offset = 16; + flush.Size = 32; + MG_Pipe::MGPipeGetResourceOps()->FlushRange(res, flush, nullptr); // off the context thread: queued + ASSERT_FALSE(Twin(res)->pendingRanges.empty()) << "the flush queued nothing, so no drain is owed"; + + EXPECT_EXIT(Ensure(res), ::testing::KilledBySignal(SIGABRT), ".*"); + const std::string log = ReadLog(); + EXPECT_NE(log.find("Fatal{StageSnapshotTooNarrow, \"ensure_flush_pending\"}"), std::string::npos) + << "the abort was not the pending-coverage clamp's (M-1, RequireStagedCoverageForPendingRanges); " + "the log says: " << log; + + fixture.TearDown(); +} + +// m-5 / codex 5 (review v2 item 5: "the shipped control cannot reach the freed base"). This one can: +// glBufferData(64, data) drawn (the twin's hostBytes names the server shadow), then the context is +// lost (ServerReleaseEGLResources -> DestroyEGLContext -> OnBackendContextDestroyed -> DropAll frees +// every shadow; the twin table survives), then the context comes back and the twin is re-armed at +// its next ensure. The re-armed twin's base must be NULL. Without the null it still names the freed +// allocation and the whole-store respecify that follows reads it - the use-after-free ASan would +// name, asserted here as the dangling pointer itself. Red once by deleting the null. +TEST(StagedShadowProductionTest, ATwinSurvivingContextLossDropsItsFreedShadowBase) { + EglServerFixture fixture; + MGL_EGL_BRING_UP_OR_BAIL(fixture); + ASSERT_TRUE(fixture.MakeCurrent()); + + const MG_Pipe::MGPipeHandle res = DeclareBuffer(BufferDesc(25, 64, true)); + StageBytes(res, 0, 64, 0x66); + Bool ensured = false; + ASSERT_EQ(OnApply([&] { ensured = Ensure(res) != nullptr; }), MOBILEGL_OK); + ASSERT_TRUE(ensured); + ASSERT_NE(Twin(res)->hostBytes, nullptr) << "the drawn twin names no shadow, so there is nothing to free"; + + Server::ServerReleaseEGLResources(); + ASSERT_NE(Twin(res), nullptr) << "the twin did not survive context loss; the m-5 hazard needs a survivor"; + + EGLint major = 0; + EGLint minor = 0; + ASSERT_TRUE(Server::ServerInitializeEGLDisplay(EglServerFixture::Dpy(), &major, &minor)); + ASSERT_TRUE(Server::ServerCreateEGLPbufferSurface(EglServerFixture::Surf(), 64, 64)); + ASSERT_TRUE(fixture.MakeCurrent()); + ASSERT_EQ(OnApply([&] { ensured = Ensure(res) != nullptr; }), MOBILEGL_OK); + ASSERT_TRUE(ensured); + + EXPECT_EQ(Twin(res)->hostBytes, nullptr) + << "a twin that survived context loss still names the freed server shadow (m-5): the " + "whole-store respecify that re-armed it read freed memory (ASan: heap-use-after-free at " + "RespecifyStorageWith)"; + + fixture.TearDown(); +} + +// M-2 / ID-52 item 3, SHIPPED AND EXECUTED (joint report joint-v1.md 3, "Audit and its R-16 +// limit": the corrupt-staged-bytes perturbation existed only by hand). Under an active transport +// the server's staged copy is the draw's ONLY base; the frontend object's MappedData() - which under +// inproc is the same process's memory and always non-null - may not be read. So: a real frontend +// BufferObject whose shadow holds pattern A, a server shadow staged with pattern B through the real +// op table (what resource_subdata delivers), and the production ensure path given BOTH. The driver +// store must hold B. That is the executable form of "corrupt only the staged bytes and the draw +// shows it": B is the corruption, the upload is the draw, the mapped read-back is the picture. Red +// once by restoring the MappedData() fallback in liveHostBase(): the store then holds A, the +// client's bytes, and the R-2.5 audit could never reach a draw again. +TEST(StagedShadowProductionTest, TheEnsurePathUploadsTheServerShadowNotTheClientObjectsBytes) { + EglServerFixture fixture; + MGL_EGL_BRING_UP_OR_BAIL(fixture); + ASSERT_TRUE(fixture.MakeCurrent()); + + // The frontend object: glBufferData(64, A). Its shadow is A and it declares defined content. + Vector clientBytes(64, Uint8{0xA5}); + auto buffer = MakeShared(31u); + buffer->Respecify(clientBytes.size(), clientBytes.data()); + ASSERT_NE(buffer->MappedData(), nullptr); + ASSERT_EQ(buffer->MappedData()[0], 0xA5); + ASSERT_TRUE(buffer->HasDefinedContent()); + + // The server shadow for the twin the draw will use: B, staged the way the wire delivers it. + const MG_Pipe::MGPipeHandle res = DeclareBuffer(BufferDesc(26, 64, true)); + StageBytes(res, 0, 64, 0x5B); + + // The production ensure, WITH the frontend object (the draw-time shape), on the apply thread; + // then the driver's store read back through a READ map on the same thread. + Vector store(64, 0); + Bool ensured = false; + Bool mapped = false; + ASSERT_EQ(OnApply([&] { + ensured = MG_Backend::DirectGLES::BufferImpl::EnsureBufferResourceForHandle(buffer, res) != nullptr; + auto* twin = Twin(res); + if (!ensured || twin == nullptr || twin->id == 0) return; + const auto& gl = MG_Backend::DirectGLES::g_GLESFuncs; + gl.glBindBuffer(GL_ARRAY_BUFFER, twin->id); + void* view = gl.glMapBufferRange(GL_ARRAY_BUFFER, 0, 64, GL_MAP_READ_BIT); + if (view != nullptr) { + std::memcpy(store.data(), view, 64); + gl.glUnmapBuffer(GL_ARRAY_BUFFER); + mapped = true; + } + }), + MOBILEGL_OK); + ASSERT_TRUE(ensured); + ASSERT_TRUE(mapped) << "the driver store could not be mapped for reading, so the case cannot see the upload"; + + EXPECT_EQ(store[0], 0x5B) + << "the ensure path uploaded the CLIENT object's bytes (MappedData(), pattern A = 0xA5) rather " + "than the server shadow (pattern B = 0x5B): under an active transport the staged copy must " + "be the draw's only base (M-2 / ID-52 item 3), or a corrupt staged upload renders the " + "frontend's correct bytes and the R-2.5 audit cannot reach a draw"; + EXPECT_EQ(store[63], 0x5B); + // And the frontend object was not written through: its shadow is still A. + EXPECT_EQ(buffer->MappedData()[0], 0xA5); + + fixture.TearDown(); +} +#endif // !_WIN32 + int main(int argc, char** argv) { // Before anything logs: MG_Util::Debug::InitFile() reads the variable once, on the first // write, and caches the FILE*. The name carries this process's pid, because