From a7e55ee62ae4b3e0b8313e284cd2375a3c7d6fc4 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Thu, 17 Sep 2026 06:08:54 -0400 Subject: [PATCH] [MG_Pipe, MG_Remote, MG_Backend, MG_Impl, MG_IntegrationTest] (Disaggregated): P5c ct - the applier_reset and object_death control records Two appended wire records replace the two cross-role control bypasses: applier_reset (opcode 77, MGPApplierReset{Uint64 ContextSerial}, kScreen) is emitted by the GL thread at the FreshlyPrimed make-current edge, before the client-side resets, so the record's barrier orders the server's MGPipeApplierReset against every verb that follows; a direct MGPipeApplierReset() from a non-apply thread with a live session is Fatal{RoleViolation, "g_applier"} (MGPipeApply.cpp carries the layer-2 guard; the apply-thread and no-session bring-up shapes keep the direct call verbatim). object_death (opcode 78, reuses MGPipeHandleOnly, kCtxObject) is the framebuffer family's first wire delete opcode: the client thread resolves the dying object's handle in its OWN allocator, emits nothing for a handle the server never saw, and EmitAndWaits otherwise - the wait preserving the death's ordering against in-flight records that name the handle, the only property the blocking mailbox hop provided. The sink releases per-kind twins through a handle-keyed ReleaseTwinByHandle (seven kinds, SamplerViewCso's idempotent second path kept); a null arriving handle is Fatal{ProtocolCorruption, "ObjectDeath.Handle"}. A documented NoSession fallback arm delivers a death to a server-only fixture's loop; monolith keeps the mailbox hop and the FreshlyPrimed direct call character for character. Evidence: gen_pipe --check/--self-test and gen_pipe_field_ownership --check/--self-test green; unit 2155/2155; integration-split 111/111 (107 plus four new CtWireScenario entries, all genuinely run, each with its own log path and an EXPECT_EXIT death case); red-once - shorting the emission to the direct call turns the four Ct cases red with Fatal{RoleViolation, "g_applier"} by name, then restores green verbatim. CONTRACT-P5C sections 1, 5.1-5.2, 7. --- MobileGL/MG_Backend/DirectGLES/Managers.cpp | 69 ++++- MobileGL/MG_Backend/DirectGLES/Managers.h | 46 ++- MobileGL/MG_Backend/DirectGLES/SlotTables.h | 22 ++ MobileGL/MG_Impl/Pipe/PipeFill.cpp | 32 ++- MobileGL/MG_IntegrationTest/CMakeLists.txt | 23 ++ .../Scenarios/CtWireScenario.cpp | 244 ++++++++++++++++ MobileGL/MG_Pipe/MGPipeTypes.h | 27 ++ MobileGL/MG_Pipe/PipeApply.cpp | 29 ++ MobileGL/MG_Pipe/PipeCalls.def | 38 ++- MobileGL/MG_Pipe/PipeFields.def | 7 + MobileGL/MG_Pipe/generated/PipeTables.inc | 12 +- MobileGL/MG_Pipe/generated/PipeThunks.inc | 8 + MobileGL/MG_Pipe/generated/PipeVerify.inc | 10 +- MobileGL/MG_Pipe/generated/PipeWire.inc | 28 +- MobileGL/MG_Remote/Client/WireTables.cpp | 79 ++++++ MobileGL/MG_Remote/Client/WireTables.h | 29 ++ MobileGL/MG_Remote/Server/PipeApplier.cpp | 65 +++++ MobileGL/MG_Remote/Server/PipeApplier.h | 34 +++ MobileGL/MG_Remote/Wire/PipeWireCodec.cpp | 18 +- MobileGL/MG_Remote/Wire/PipeWireCodec.h | 19 ++ MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp | 46 ++- MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp | 265 ++++++++++++++++++ 22 files changed, 1114 insertions(+), 36 deletions(-) create mode 100644 MobileGL/MG_IntegrationTest/Scenarios/CtWireScenario.cpp diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index 42178fb0..28dcfbb2 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -22,6 +22,10 @@ #include #include #include +// P5c (ct): object_death's producer (CONTRACT-P5C.md §5.2) - the death notice's split arm +// emits the record through the client's emit helper instead of hopping a stack struct to the +// apply thread. +#include #endif #include "Utils.h" @@ -207,10 +211,34 @@ namespace MobileGL::MG_Backend::DirectGLES { void OnFrontendStateObjectDestroyed(MG_Pipe::MGPipeKind kind, Uint64 lifetimeId) { if (InProcessTeardown()) return; #if MOBILEGL_BUILD_DISAGGREGATED - // Death notices have no framebuffer wire opcode. Keep the lifetime/slot valid - // until the context owner has destroyed its twin and updated its binding cache. + // P5c (ct), CONTRACT-P5C.md §5.2: with an active transport the death crosses AS A + // RECORD - object_death, the framebuffer family's first wire delete opcode. The GL + // thread resolves the dying object's handle in its OWN allocator inside + // EmitObjectDeathRecord: no handle means the server never saw the object and + // NOTHING crosses (which replaces the mailbox's unconditional delivery), and a + // handle means the record's EmitAndWait orders the death against in-flight verbs + // that name it - the one property the blocking RunOnApplyThread hop provided and + // the only one P5c keeps. The sink (ServerVerbSink::OnObjectDeath) releases the + // kind's twin table by handle on the apply thread, so neither a lifetime-id probe + // nor the client's allocator is touched from there any more. if (MG_Config::Transport != MG_Config::TransportMode::Monolith && !MG_Remote::Server::ServerLoop::OnApplyThread()) { + if (MG_Remote::Client::EmitObjectDeathRecord(kind, lifetimeId) != + MG_Remote::Client::ObjectDeathEmit::NoSession) { + return; + } + // NoSession is the ONE shape the record cannot carry: a transport configured + // with no client session at all - a ServerLoop fixture driving a server role + // with no client, never a real split (there a session exists whenever the + // server does). The server loop IS there, so the death takes the delivery + // that predates the record, kept VERBATIM for exactly this arm: a stack + // struct hopped to the apply thread, which runs the switch below. NoHandle + // does NOT land here - a handle that never existed has no twin to kill (§5.2) + // - and a loop that is not RUNNING (teardown, pre-Start) has no thread to hop + // to; the twin dies with the server either way. + if (!MG_Remote::Server::ServerLoopInstance().Running()) { + return; + } struct Death { MG_Pipe::MGPipeKind kind; Uint64 lifetimeId; } death{kind, lifetimeId}; MG_Remote::Server::ServerLoopInstance().RunOnApplyThread( +[](void* user) -> MobileGLResult { @@ -290,6 +318,43 @@ namespace MobileGL::MG_Backend::DirectGLES { }; } // namespace +#if MOBILEGL_BUILD_DISAGGREGATED + Bool ReleaseTwinsForWireObjectDeath(MG_Pipe::MGPipeHandle handle, MG_Pipe::MGPipeKind kind) { + // The handle-keyed twin of the notice switch above, and deliberately NOT that switch: + // the record carried the handle, so no lifetime id is probed and the client's + // allocator - a client-only surface under a transport (CONTRACT-P5C.md §3.1) - is + // never touched from the apply thread. Each arm names its kind's registry GLOBAL for + // the same spelling reason as the notice arms: ReleaseByHandle / + // ReleaseTwinByHandle is static and is answered by every table of the kind that + // exists, this global's own and any by-value copy a fixture or a context reset holds. + switch (kind) { + case MG_Pipe::MGPipeKind::Texture: + return TextureImpl::g_backendTextureObjects.ReleaseByHandle(handle); + case MG_Pipe::MGPipeKind::Framebuffer: + return FramebufferImpl::g_backendFramebufferObjects.ReleaseByHandle(handle); + case MG_Pipe::MGPipeKind::Renderbuffer: + return RenderbufferImpl::g_backendRenderbufferObjects.ReleaseByHandle(handle); + case MG_Pipe::MGPipeKind::SamplerCso: + return SamplerImpl::g_backendSamplerObjects.ReleaseByHandle(handle); + case MG_Pipe::MGPipeKind::ShaderCso: + return PrgramImpl::g_backendProgramObjects.ReleaseByHandle(handle); + case MG_Pipe::MGPipeKind::SamplerViewCso: + // The notice arm's REDUNDANT SECOND PATH, keyed by the view's handle the same + // way (CONTRACT-P5C.md §5.2): the client's delete_sampler_view may already have + // released the twin, in which case the generation check inside fails and this + // answers false. A false here is idempotency, never a leak - the view twin owns + // no driver id of its own. + return SamplerViewImpl::BackendSamplerViewTable::ReleaseTwinByHandle(handle); + case MG_Pipe::MGPipeKind::VertexElementsCso: + return VertexArrayImpl::g_backendVertexArrayObjects.ReleaseByHandle(handle); + default: + // Buffer's death crosses as resource_destroy (P3a), exactly as the notice + // switch's default arm rules; every other kind has no twin table here. + return false; + } + } +#endif + // The one sentence that decides the arm, written once so that a test can drive every // combination of the two knobs and so that bring-up and first-use cannot disagree. EsprytSlotArmVerdict ClassifyEsprytSlotArm(Bool subsystemBitSet, Bool legacyMemosEnabled) { diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.h b/MobileGL/MG_Backend/DirectGLES/Managers.h index 38359e4d..aad23cd5 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.h +++ b/MobileGL/MG_Backend/DirectGLES/Managers.h @@ -467,17 +467,30 @@ namespace MobileGL::MG_Backend::DirectGLES { return m_slotTable.LiveGenAt(slot); } - // NO ReleaseByHandle HERE, AND THAT IS A DECISION (review M-4). The death half of - // GetOrCreateByHandle exists for a kind whose announcement is its own destroy CALL - // rather than the shared death notice - which is the BUFFER family + // NO ReleaseByHandle HERE THROUGH P5, AND THAT WAS A DECISION (review M-4). The death + // half of GetOrCreateByHandle existed only for a kind whose announcement is its own + // destroy CALL rather than the shared death notice - which was the BUFFER family // (BackendBufferResourceTable::ReleaseByHandle, SlotTables.h, called from - // resource_destroy) and none of the five kinds this registry serves: every one of them - // dies through DestroyByLifetimeId below, because P4a adds no server-side destroy arm - // for a texture, a renderbuffer, a framebuffer, a sampler CSO or a shader CSO. v1 - // declared one here anyway and it had no caller on either arm, which made its bound and - // its wording things nobody would exercise until P5. The one-line wrapper comes back in - // the commit that gives it a caller; SlotTable::ReleaseByHandle underneath is untouched - // and is what SanityTest drives directly. + // resource_destroy) and none of the five kinds this registry serves: every one of + // them died through DestroyByLifetimeId below, because P4a added no server-side + // destroy arm for a texture, a renderbuffer, a framebuffer, a sampler CSO or a shader + // CSO. P5c (ct) is the commit that gives the wrapper its caller: object_death carries + // the dead object's HANDLE on the wire (CONTRACT-P5C.md §5.2), and the static + // ReleaseByHandle below is what the sink's per-kind dispatch calls. + + // P5c (ct), CONTRACT-P5C.md §5.2: the wrapper M-4 below deferred, given its caller by + // object_death. The record carried the handle, so the release is keyed by it and the + // client's allocator is never asked from this side (rule E); every holder of the kind + // lets go, exactly as DestroyByLifetimeId walks them. What this does NOT do is the + // allocator Free the notice arm performs - the slot's owner is the client, which + // already returned it after the record went out. STATIC for the same reason + // DestroyByLifetimeId is: a death is about an object, not a table instance. + static Bool ReleaseByHandle(MG_Pipe::MGPipeHandle handle) { + if (EsprytSlotTablesEnabled()) { + return SlotTable::ReleaseTwinByHandle(handle); + } + return false; + } // P2 step e2. STATIC, because a death notice is about an object and not about a // registry instance: it is answered by EVERY table of this kind that exists - this @@ -2773,4 +2786,17 @@ namespace MobileGL::MG_Backend::DirectGLES { extern TwinRegistry g_backendRenderbufferObjects; } // namespace RenderbufferImpl + +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (ct), CONTRACT-P5C.md §5.2: object_death's per-kind release, one entry point for all + // seven kinds for the same reason the notice switch is one - the answer is the same for + // all of them: every holder of the kind's twin table lets go of the twin at this handle. + // Called from ServerVerbSink::OnObjectDeath ON THE APPLY THREAD, with the handle the + // record carried; the client's allocator is never consulted (rule E). Returns whether any + // table released a twin - false for a kind this backend does not twin (Buffer: its death + // crosses as resource_destroy) and for a handle no holder holds, which the kind's own + // delete opcode may already have released (the idempotent-second-path shape the notice + // arms document). + Bool ReleaseTwinsForWireObjectDeath(MG_Pipe::MGPipeHandle handle, MG_Pipe::MGPipeKind kind); +#endif } // namespace MobileGL::MG_Backend::DirectGLES diff --git a/MobileGL/MG_Backend/DirectGLES/SlotTables.h b/MobileGL/MG_Backend/DirectGLES/SlotTables.h index a84e6e72..cc82c565 100644 --- a/MobileGL/MG_Backend/DirectGLES/SlotTables.h +++ b/MobileGL/MG_Backend/DirectGLES/SlotTables.h @@ -429,6 +429,28 @@ namespace MobileGL::MG_Backend::DirectGLES { return true; } + // P5c (ct), CONTRACT-P5C.md §5.2: the handle-keyed half of the above, for a death that + // arrived AS A WIRE RECORD (object_death) rather than as the shared notice. The + // handle IS the resolution - it crossed in the record's payload - so the client's + // allocator is never asked: under an active transport MGPipeSlots() is a client-only + // surface (rule E, §3.1) and this function runs on the apply thread. Every holder + // lets go exactly as the notice arm does, in the same successor-first order. What + // does NOT happen here is the allocator Free: the slot's owner - the client - + // returned it itself after the record went out (PipeFill.cpp's NotifyAndFree), and a + // double Free would be refused by generation anyway. Idempotent against the kind's + // own delete opcode, exactly as the notice arm is: a twin the delete already released + // fails ReleaseTwinAt's generation check and the walk moves on. + static Bool ReleaseTwinByHandle(MG_Pipe::MGPipeHandle handle) { + if (MG_Pipe::MGPipeHandleIsNull(handle)) return false; + Bool released = false; + for (BackendSlotTable* holder = s_firstHolder; holder != nullptr;) { + BackendSlotTable* const next = holder->m_nextHolder; + released = holder->ReleaseTwinAt(handle) || released; + holder = next; + } + return released; + } + // How many tables of this type exist right now. For the tests that pin the holder // list; nothing on a shipping path asks. static Uint32 HolderCount() { diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp index dd359989..5a375049 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.cpp +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -2690,8 +2690,38 @@ namespace MobileGL::MG_Pipe { // - what the server has is no longer what any suppressor slot last emitted; // - and the residual block owes a fresh publication whatever else moved. if (tracker.FreshlyPrimed()) { +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (ct), CONTRACT-P5C.md §5.1: with an active transport the server's reset + // crosses AS A RECORD, ahead of every reset below - the client-side ones (the CSO + // cache, the hash suppressor, the vertex-input emitter) and the emitters' latches + // - because the record's barrier is what orders the server's MGPipeApplierReset() + // against every verb that follows. The GL-thread direct call it replaces is + // Fatal{RoleViolation, "g_applier"} inside MGPipeApplierReset itself (§6 layer 2), + // so reverting this arm to the direct call goes red by name rather than rendering + // stale. Monolith keeps the direct call, byte for byte (G1). + // + // THE APPLY THREAD IS EXCLUDED (M5's rule): a validate running on the server's own + // thread produces no client record - EmitAndWait there would wait on the thread + // that has to apply the record - and the direct call is exactly what the apply + // thread is allowed to make (the sink's own path runs it there). + // + // AND A CONFIGURED-BUT-WIRELESS TRANSPORT TAKES THE DIRECT CALL TOO: + // EmitApplierResetRecord answers false when no live session could carry the + // record (the pre-Start bring-up window, a ServerLoop fixture with no client at + // all), and in that shape this process IS the only place the reset can run. + const Bool transportActive = + MG_Config::Transport != MG_Config::TransportMode::Monolith && + !MG_Remote::Client::RunsAsTheServerRole(); + Bool resetCrossed = false; + if (transportActive) { + resetCrossed = MG_Remote::Client::EmitApplierResetRecord(); + } +#endif MGPipeCsoCacheInstance().Reset(); - MGPipeApplierReset(); +#if MOBILEGL_BUILD_DISAGGREGATED + if (!resetCrossed) +#endif + MGPipeApplierReset(); MGPipeSetHashSuppressorInstance().InvalidateAll(); // P3a: and the vertex-input emitter's latches. NOT because the applier dropped // its vertex-elements records - it does not, they are share-group object state diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index cdc2d15b..06974e5e 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -2172,6 +2172,29 @@ if (MOBILEGL_BUILD_DISAGGREGATED) endforeach() endif() +# P5c ct (MG_Remote/CONTRACT-P5C.md §5): the two control records, split-only source for the +# same reason as f1's - the scenario reads the SERVER sink's tallies, which a monolith build +# has no symbol for. One block per case with a LOG PATH OF ITS OWN, the F1 pattern: +# SplitLogPaths.PrivateAndDistinct fails a lane whose cases share one file, and the death +# case's child truncates whatever path it inherits. +if (MOBILEGL_BUILD_DISAGGREGATED) + target_sources(MobileGLIntegrationTest PRIVATE Scenarios/CtWireScenario.cpp) + foreach(ctCase ApplierResetCrossesAtThePrimedEdgeInSerialOrder + TextureDeathCrossesAndTheRecycledSlotAnswersTheNewObject + FramebufferDeathCrossesAndTheRecycledSlotAnswersTheNewObject + TheDirectApplierResetCallOnTheGLThreadIsRoleViolation) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.Split.Ct." + TEST_FILTER "CtWireScenario.${ctCase}" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-split" + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_SPLIT_ENVIRONMENT}\;MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/p5c-ct-${ctCase}.log" + ) + endforeach() +endif() + # P5b measured named-blit blockers, exercising both backend bound-form lowerings. if (MOBILEGL_BUILD_DISAGGREGATED) foreach(namedBlitBackend DirectGLES DirectVulkan) diff --git a/MobileGL/MG_IntegrationTest/Scenarios/CtWireScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/CtWireScenario.cpp new file mode 100644 index 00000000..2f768a71 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/CtWireScenario.cpp @@ -0,0 +1,244 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/CtWireScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// P5c ct (MG_Remote/CONTRACT-P5C.md §5): the two control records, end to end over inproc. +// +// applier_reset (§5.1): this process's first validate primes the pipe tracker, and the +// FreshlyPrimed edge is the record's producer (MG_Impl/Pipe/PipeFill.cpp). What the scenario +// asserts is the SERVER sink's own counters - moved by nobody else - plus the pixels, so a +// reset that never crossed is red by the tally and a client that fell through to the driver +// is red by ScenarioFixture's emit-ordinal rule. +// +// object_death (§5.2): a texture and a framebuffer die, the deaths cross, and the slots +// recycle. The picture after recycling is the behavioural half: a stale twin answering for +// the recycled object renders the DEAD object's content (or errors), which is the exact +// shape LiveGenAt's generation check exists to refuse. +// +// RED ONCE for the reset (executed, recorded in the package report): reverting PipeFill's +// FreshlyPrimed arm to the GL-thread direct MGPipeApplierReset() call aborts this whole lane +// with Fatal{RoleViolation, "g_applier"} out of the applier's layer-2 guard. The automated +// half of that control is TheDirectApplierResetCallOnTheGLThreadIsRoleViolation below, and +// the guard's two non-Fatal arms are pinned in PipeWireCodecTest's CtWireFatals suite. + +#include "../Harness/ScenarioFixture.h" +#include "../Harness/SplitRuntimePeek.h" + +#include +#include + +#if !defined(_WIN32) +#include +#include +#include +#include +#endif + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { +namespace { + + MobileGL::MG_Remote::Server::ServerVerbSink& ServerVerbs() { + return MobileGL::MG_Remote::Server::ServerSessionInstance().Applier().Verbs(); + } + + class CtWireScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + const auto why = SplitRuntimeSkipReason(); + if (!why.empty()) GTEST_SKIP() << why; + } + + // One 4x4 RGBA8 texture whose every texel is `rgba`, uploaded so the object crosses + // (no handle, no record - CONTRACT-P5C.md §5.2: an object that never crossed emits + // nothing at death, and this case needs a real death). + GLuint MakeSolidTexture(const GLubyte rgba[4]) { + GLuint texture = 0; + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 4, 4, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); + GLubyte texels[4 * 4 * 4]; + for (int i = 0; i < 4 * 4; ++i) { + for (int c = 0; c < 4; ++c) texels[i * 4 + c] = rgba[c]; + } + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 4, 4, GL_RGBA, GL_UNSIGNED_BYTE, texels); + return texture; + } + + // The texture's level-0 content, read back through a throwaway FBO, as four bytes. + // The FBO is bound AND deleted here, so it never leaks a framebuffer death into the + // tally a case is watching. + void ReadTextureLevel(GLuint texture, GLubyte out[4]) { + GLuint fbo = 0; + glGenFramebuffers(1, &fbo); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0); + glReadPixels(1, 1, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, out); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glDeleteFramebuffers(1, &fbo); + } + }; + + TEST_F(CtWireScenario, ApplierResetCrossesAtThePrimedEdgeInSerialOrder) { + if (!Ready()) return; + // The first verb of the process primes the tracker; the edge is the producer. The + // clear also gives the case its pixels, so a lane that emitted nothing is red twice + // (here and in the fixture's emit-ordinal rule). + glClearColor(0.2f, 0.4f, 0.6f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + EXPECT_GE(ServerVerbs().ApplierResets(), 1u) + << "the FreshlyPrimed edge emitted no applier_reset; the server's g_applier was " + "never reset for this session"; + // The serial sequence is the session's own count (§1: asserted, never dispatched + // on): every record the sink accepted carried the serial it expected, or the counter + // and the accepted tally would disagree. + EXPECT_EQ(ServerVerbs().ExpectedApplierResetSerial(), ServerVerbs().ApplierResets()) + << "an applier_reset record was dropped or replayed on the wire"; + + GLubyte pixel[4]{}; + glReadPixels(1, 1, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "Ct.ApplierReset.error"; + const int expected[4] = {51, 102, 153, 255}; + for (int i = 0; i < 4; ++i) EXPECT_NEAR(pixel[i], expected[i], 1) << "Ct.ApplierReset.pixels"; + } + + TEST_F(CtWireScenario, TextureDeathCrossesAndTheRecycledSlotAnswersTheNewObject) { + if (!Ready()) return; + const GLubyte red[4] = {255, 0, 0, 255}; + const GLubyte green[4] = {0, 255, 0, 255}; + + // A texture lives and crosses (the upload emits its records). It is also READ BACK + // once before it dies: the read forces the server-side sync that creates the twin - + // and, one level down, resolves the Espryt slot arm that INSTALLS the death-notice + // ops, which no process has before its first twin lookup. The red picture is the + // pre-death control the recycle below is read against. + GLuint texture = MakeSolidTexture(red); + GLubyte before[4]{}; + ReadTextureLevel(texture, before); + const int redExpected[4] = {255, 0, 0, 255}; + for (int i = 0; i < 4; ++i) ASSERT_NEAR(before[i], redExpected[i], 1) << "Ct.TextureDeath.before"; + + // The object dies unbound so the destructor - and the death record - fire at the + // delete, and the tally is read AFTER the EmitAndWait the delete blocked on. + glBindTexture(GL_TEXTURE_2D, 0); + const MobileGL::Uint64 deathsBefore = ServerVerbs().ObjectDeaths(); + glDeleteTextures(1, &texture); + EXPECT_GT(ServerVerbs().ObjectDeaths(), deathsBefore) + << "the texture's death produced no object_death record; the server's twin was " + "never told to let go"; + + // The slot recycles forward: a new texture (the frontend hands the same GL name back + // more often than not, which is exactly the ABA shape) must answer with ITS content, + // not the dead twin's. Red then green, read back as pixels: a stale twin is red. + texture = MakeSolidTexture(green); + GLubyte pixel[4]{}; + ReadTextureLevel(texture, pixel); + glBindTexture(GL_TEXTURE_2D, 0); + glDeleteTextures(1, &texture); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "Ct.TextureDeath.error"; + const int expected[4] = {0, 255, 0, 255}; + for (int i = 0; i < 4; ++i) EXPECT_NEAR(pixel[i], expected[i], 1) + << "Ct.TextureDeath.pixels - the recycled texture read back the dead twin's content"; + } + + TEST_F(CtWireScenario, FramebufferDeathCrossesAndTheRecycledSlotAnswersTheNewObject) { + if (!Ready()) return; + // Framebuffer is the kind object_death EXISTS for: it has no other wire delete + // opcode (CONTRACT-P5C.md §5.2). The renderbuffer goes along so the FBO has storage. + GLuint fbo = 0, renderbuffer = 0; + glGenFramebuffers(1, &fbo); + glGenRenderbuffers(1, &renderbuffer); + glBindRenderbuffer(GL_RENDERBUFFER, renderbuffer); + glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, 4, 4); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, renderbuffer); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE)) + << "Ct.FramebufferDeath.setup"; + glClearColor(0.0f, 0.0f, 1.0f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + GLubyte pixel[4]{}; + glReadPixels(1, 1, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + const int blue[4] = {0, 0, 255, 255}; + for (int i = 0; i < 4; ++i) ASSERT_NEAR(pixel[i], blue[i], 1) << "Ct.FramebufferDeath.first"; + + // Die unbound, framebuffer first so the attachment's own death is a separate record. + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glBindRenderbuffer(GL_RENDERBUFFER, 0); + const MobileGL::Uint64 deathsBefore = ServerVerbs().ObjectDeaths(); + glDeleteFramebuffers(1, &fbo); + EXPECT_GT(ServerVerbs().ObjectDeaths(), deathsBefore) + << "the framebuffer's death produced no object_death record - and no other " + "opcode can carry it"; + glDeleteRenderbuffers(1, &renderbuffer); + + // Recycle: a new FBO and a new backing store, cleared to a different colour. A stale + // twin answering for the recycled framebuffer renders the blue of the dead one. + glGenFramebuffers(1, &fbo); + glGenRenderbuffers(1, &renderbuffer); + glBindRenderbuffer(GL_RENDERBUFFER, renderbuffer); + glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, 4, 4); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, renderbuffer); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE)) + << "Ct.FramebufferDeath.recycle"; + glClearColor(1.0f, 1.0f, 0.0f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + glReadPixels(1, 1, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glBindRenderbuffer(GL_RENDERBUFFER, 0); + glDeleteFramebuffers(1, &fbo); + glDeleteRenderbuffers(1, &renderbuffer); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "Ct.FramebufferDeath.error"; + const int yellow[4] = {255, 255, 0, 255}; + for (int i = 0; i < 4; ++i) EXPECT_NEAR(pixel[i], yellow[i], 1) + << "Ct.FramebufferDeath.pixels - the recycled framebuffer read back the dead twin's content"; + } + +#if !defined(_WIN32) + TEST_F(CtWireScenario, TheDirectApplierResetCallOnTheGLThreadIsRoleViolation) { + if (!Ready()) return; + // A verb in the PARENT, so the fixture's emit-ordinal rule has something to see: the + // death statement below runs only in the child, and a parent that emitted nothing + // is the shape that rule exists to fail. + glClearColor(0.1f, 0.1f, 0.1f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + // THE RED-ONCE FOR §5.1, AUTOMATED. This process has a LIVE inproc session, and the + // GL-thread direct call the applier_reset record replaced is a named Fatal there + // (CONTRACT-P5C.md §5.1 / §6 layer 2) - which is exactly the shape reverting + // PipeFill's FreshlyPrimed arm would take, so the guard is what turns that revert + // red. EXPECT_EXIT re-runs the case in a child process: the child brings up its own + // session (its SetUp is this same fixture's) and aborts at the statement. The two + // NON-Fatal arms - monolith, and a transport with no client session - are the unit + // suite's CtWireFatals.TheDirectApplierResetCallSurvivesMonolithAndAWirelessTransport. + // + // The regex is ".*" because the library's Fatal line goes to its log file, not to + // the stderr a death test matches (ServerLoopEglTest's own EXPECT_EXIT does the + // same); the NAME is asserted from the log below, which the child truncated and + // wrote before it died. + EXPECT_EXIT(MobileGL::MG_Pipe::MGPipeApplierReset(), ::testing::KilledBySignal(SIGABRT), ".*"); + if (const char* logPath = std::getenv("MOBILEGL_LOG_FILE_PATH"); logPath != nullptr) { + std::ifstream in(logPath, std::ios::binary); + std::ostringstream log; + log << in.rdbuf(); + EXPECT_NE(log.str().find("Fatal{RoleViolation, \"g_applier\"}"), std::string::npos) + << "the child aborted, but not with the layer-2 guard's own line:\n" + << log.str(); + } + } +#endif + +} // namespace +} // namespace MGITest diff --git a/MobileGL/MG_Pipe/MGPipeTypes.h b/MobileGL/MG_Pipe/MGPipeTypes.h index 7ebbb32d..b384ffe2 100644 --- a/MobileGL/MG_Pipe/MGPipeTypes.h +++ b/MobileGL/MG_Pipe/MGPipeTypes.h @@ -1633,6 +1633,33 @@ namespace MobileGL::MG_Pipe { }; MGP_ASSERT_POD(MGPCopyFromFramebuffer, 48); + // --------------------------------------------------------------------------------- + // P5c: the two appended rows (MG_Remote/CONTRACT-P5C.md §5). APPENDED, never inserted: + // applier_reset is opcode 77 and object_death opcode 78, and no earlier opcode moved. + // + // Neither has an MGPipeApply* entry point and neither gains one. Both reach + // MG_Remote::Wire::WireVerbSink like the five P5b verbs, and under monolith neither has a + // producer - the reset is the GL thread's direct MGPipeApplierReset() call and the death + // notice's mailbox hop, byte for byte as today (G1/G2). + // --------------------------------------------------------------------------------- + + // applier_reset = tracker.FreshlyPrimed()'s server half (P5c §5.1). The one field is the + // client-side context's make-current serial at the edge that primed it: 0 is the first + // make-current. P5c has exactly one context per session, so the sink ASSERTS the value + // against the session's own count rather than dispatching on it; P6's multi-context shape + // is what reads it for real. + struct MGPApplierReset { + Uint64 ContextSerial; + }; + MGP_ASSERT_POD(MGPApplierReset, 8); + + // object_death carries no payload of its own: MGPHandleOnly (:93-98) IS the payload + // (CONTRACT-P5C.md §1) - Handle the dead frontend object's handle, Kind its MGPipeKind + // widened, one row for all seven kinds the death switch handles. A null handle never + // crosses: the client emits nothing when its own allocator cannot resolve the dying + // object (§5.2), so a null handle arriving is Fatal{ProtocolCorruption, + // "ObjectDeath.Handle"} at the sink. + // --------------------------------------------------------------------------------- // Reverse channel payloads (section 7.1) // --------------------------------------------------------------------------------- diff --git a/MobileGL/MG_Pipe/PipeApply.cpp b/MobileGL/MG_Pipe/PipeApply.cpp index cd68a2bb..7b7453b1 100644 --- a/MobileGL/MG_Pipe/PipeApply.cpp +++ b/MobileGL/MG_Pipe/PipeApply.cpp @@ -37,6 +37,13 @@ // MOBILEGL_PIPE_VERIFY block above on purpose: the tier is a property of the BUILD, not of the // comparator, and a split build without the comparator still declines every acquisition. #include +// P5c (ct), CONTRACT-P5C.md §6 layer 2: MGPipeApplierReset's role guard asks +// ServerLoop::OnApplyThread() - the one predicate that tells the GL thread from the thread +// that owns g_applier under an active transport - and ClientSession::Active(), which is what +// tells a live wire from a configured-but-wireless one (the bring-up window and the +// server-role-only fixture: there the direct call is the only reset that exists). +#include +#include #endif #include @@ -1212,6 +1219,28 @@ namespace MobileGL::MG_Pipe { } // namespace void MGPipeApplierReset() { +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (ct), CONTRACT-P5C.md §5.1 / §6 layer 2. With an active transport g_applier is + // SERVER-PRIVATE and the GL thread's reset crosses as the applier_reset RECORD + // (PipeFill.cpp's FreshlyPrimed arm emits it; ServerVerbSink::OnApplierReset runs this + // function ON the apply thread). Reaching here from any other thread WITH A LIVE WIRE + // is the direct call the record replaced - a write into server memory with no wire + // shape, and exactly the revert the red-once gate must catch - so it is a named Fatal + // rather than a silent reset of state another role owns. The window the Fatal + // deliberately does NOT cover is a configured-but-wireless transport: before + // ClientSession::Start() there is no wire for a record to cross (§6 layer 2's + // documented bring-up exception), and a ServerLoop fixture with no client at all has + // none either - there this call is the only reset that exists. Monolith keeps the + // direct call, byte for byte (G1); in a pull build none of this is compiled at all. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith && + MG_Remote::Client::ClientSession::Active() != nullptr && + !MG_Remote::Server::ServerLoop::OnApplyThread()) { + MGLOG_F("MGPipe: Fatal{RoleViolation, \"g_applier\"} - MGPipeApplierReset() called " + "off the apply thread with an active transport; under split the reset " + "crosses as the applier_reset record (CONTRACT-P5C.md §5.1)"); + std::abort(); + } +#endif g_applier.RenderStateCsos.clear(); g_applier.BoundRenderStateCso = kMGPipeNullHandle; g_applier.Residual = ResidualValueBlock{}; diff --git a/MobileGL/MG_Pipe/PipeCalls.def b/MobileGL/MG_Pipe/PipeCalls.def index bcfeb0e9..3c4103fd 100644 --- a/MobileGL/MG_Pipe/PipeCalls.def +++ b/MobileGL/MG_Pipe/PipeCalls.def @@ -41,17 +41,18 @@ // that the expansion, the two generated tables and this number agree. // // class entries group (as the plan tabulates it) -// kScreen 11 screen: caps 1 + resource 3 + persistent map 2 + fence 4, plus the -// appended server-side fence wait 1 +// kScreen 12 screen: caps 1 + resource 3 + persistent map 2 + fence 4, plus the +// appended server-side fence wait 1 and P5c's applier_reset 1 // kCtxQuery 8 query object namespace 6, plus the appended timestamp pair 2 // kCtxCso 13 CSO create/bind/delete // kCtxState 17 16 of the 17 set_* calls + the temporary set_residual_value_state -// kCtxObject 9 set_texture_params (the 17th set_*) + 8 object-scoped transfers +// kCtxObject 10 set_texture_params (the 17th set_*) + 8 object-scoped transfers, plus +// P5c's object_death 1 // kCtxVerb 18 3 context-reading transfer calls + the 10 commands, plus the five // P5b-appended verbs (bind_shader_image, patch_parameter, // bind_stream_output, set_storage_block_binding, // copy_framebuffer_to_texture - MG_Remote/CONTRACT-P5B.md) -// total 76 +// total 78 // // Reconciliation with the plan's headline numbers (section 4.4 / appendix A), because they // do not add up to a set of UNIQUE records and this file has to hold unique records: @@ -88,9 +89,15 @@ // 76 unique records. The catalogue footer's "ShaderStorageBlockBinding is folded into the // reflection archive" is CORRECTED by the row: the archive carries the bindings the // program LINKED with, and glShaderStorageBlockBinding moves one AFTER link. +// - P5c (MG_Remote/CONTRACT-P5C.md §5.1/§5.2) appended TWO more after those (opcodes 77..78), +// same rule: applier_reset, the make-current edge's server-side reset that used to be a +// GL-thread direct call into server memory, and object_death, the framebuffer family's +// FIRST wire delete opcode and the handle-keyed replacement for the death-notice mailbox. +// Neither has an MGPipeApply* entry point and neither gains one; both reach WireVerbSink. +// 78 unique records. // --------------------------------------------------------------------------------------- -#define MGP_CALL_LIST_DOCUMENTED_COUNT 76 +#define MGP_CALL_LIST_DOCUMENTED_COUNT 78 // clang-format off #define MGP_CALL_LIST(X) \ @@ -230,7 +237,26 @@ X(SetStorageBlockBinding, MGPStorageBlockBinding, kCtxVerb, kHasBlob) \ /* glCopyTexImage2D / glCopyTexSubImage2D - a copy whose SOURCE is the read framebuffer, */ \ /* which resource_copy_region (resource -> resource) cannot express. */ \ - X(CopyFramebufferToTexture, MGPCopyFromFramebuffer, kCtxVerb, kNone) + X(CopyFramebufferToTexture, MGPCopyFromFramebuffer, kCtxVerb, kNone) \ + /* ---- APPENDED BY P5c (MG_Remote/CONTRACT-P5C.md §5): the two control records. Opcodes ---- */ \ + /* ---- 77..78. Neither has an MGPipeApply* entry point and neither gains one: both reach ---- */ \ + /* ---- WireVerbSink, and under monolith neither has a producer - the reset is a direct ---- */ \ + /* ---- MGPipeApplierReset() call and the death notice's mailbox hop, byte for byte as ---- */ \ + /* ---- today (G1/G2). ---- */ \ + /* tracker.FreshlyPrimed() - a make-current is a fresh server. Under a transport the GL */ \ + /* thread may not reset the server's g_applier itself (rule E), so the reset crosses AS */ \ + /* THE CALL, ahead of the block's client-side resets; the record's barrier orders it */ \ + /* against every verb that follows. ContextSerial is the client context's make-current */ \ + /* serial at the edge - ASSERTED against the session's, never dispatched on (P5c: one */ \ + /* context per session). */ \ + X(ApplierReset, MGPApplierReset, kScreen, kNone) \ + /* A frontend state object died. The record carries the HANDLE (and kind) the client's own */ \ + /* allocator resolves for it - nothing is emitted for an object that never crossed (the */ \ + /* server never saw it, so there is no twin to kill) - and the sink releases the kind's */ \ + /* twin table by handle. The framebuffer family's FIRST wire delete opcode: its death used */ \ + /* to hop to the apply thread through a stack-struct mailbox and a lifetime-id probe of */ \ + /* the client's allocator, both rule-E surfaces. */ \ + X(ObjectDeath, MGPHandleOnly, kCtxObject, kNone) // clang-format on // Explicitly NOT migrated (plan 4.4.6 / appendix A "explicit deletions"): diff --git a/MobileGL/MG_Pipe/PipeFields.def b/MobileGL/MG_Pipe/PipeFields.def index ca0147a7..1b693073 100644 --- a/MobileGL/MG_Pipe/PipeFields.def +++ b/MobileGL/MG_Pipe/PipeFields.def @@ -262,6 +262,12 @@ F(Dst) F(Target) F(Level) F(InternalFormat) F(X) F(Y) F(Width) F(Height) F(XOffset) F(YOffset) \ F(SubImage) +// ---- P5c's two appended rows (MG_Remote/CONTRACT-P5C.md §5). object_death's payload IS +// MGPHandleOnly, whose list already exists above; applier_reset's is one field. + +#define MGP_FIELDS_MGPApplierReset(F) \ + F(ContextSerial) + // ---- the value structs and the host span (P1 brief D8). Not call payloads themselves, but // members of ones (ResidualValueBlock, MGPPixelPackState, MGPCaps) and of PipeInputs, so the // comparator has to see INTO them: with these lists the memcmp fallback of MGPipeFieldEqual is @@ -379,6 +385,7 @@ P(MGPSurfaceInfo) \ P(MGPImageBind) P(MGPPatchParameter) P(MGPStreamOutputBind) P(MGPStorageBlockBinding) \ P(MGPCopyFromFramebuffer) \ + P(MGPApplierReset) \ P(RenderStateParameters) P(PixelStoreParameters) P(SamplerParameters) P(PerBufferBlendState) \ P(StencilFaceState) \ P(DynamicBackendParameters) P(MGHostSpan) \ diff --git a/MobileGL/MG_Pipe/generated/PipeTables.inc b/MobileGL/MG_Pipe/generated/PipeTables.inc index 5786a8de..3ca5fde5 100644 --- a/MobileGL/MG_Pipe/generated/PipeTables.inc +++ b/MobileGL/MG_Pipe/generated/PipeTables.inc @@ -12,7 +12,7 @@ // Regenerate with `python3 scripts/gen_pipe.py`; CI runs it and diffs the result. // This file is included from MG_Pipe/MGPipe.h inside namespace MobileGL::MG_Pipe. -// share group: 11 calls. A null entry means the backend does not implement this +// share group: 12 calls. A null entry means the backend does not implement this // call and the frontend keeps its own path (plan B section 4.1). struct MGPipeScreen { void (*GetCaps)(const MGPCaps* payload, const void* blobBytes, Uint64 blobByteCount, MGPReplySlot* reply); @@ -26,9 +26,10 @@ struct MGPipeScreen { void (*FenceWait)(const MGPFenceWait* payload, MGPReplySlot* reply); void (*FenceDestroy)(const MGPHandleOnly* payload); void (*FenceWaitServer)(const MGPFenceWait* payload); + void (*ApplierReset)(const MGPApplierReset* payload); }; -// context: 65 calls. A null entry means the backend does not implement this +// context: 66 calls. A null entry means the backend does not implement this // call and the frontend keeps its own path (plan B section 4.1). struct MGPipeContext { void (*QueryCreate)(const MGPQueryDesc* payload); @@ -96,11 +97,12 @@ struct MGPipeContext { void (*BindStreamOutput)(const MGPStreamOutputBind* payload); void (*SetStorageBlockBinding)(const MGPStorageBlockBinding* payload, const void* blobBytes, Uint64 blobByteCount); void (*CopyFramebufferToTexture)(const MGPCopyFromFramebuffer* payload); + void (*ObjectDeath)(const MGPHandleOnly* payload); }; -inline constexpr SizeT kMGPipeScreenCallCount = 11; -inline constexpr SizeT kMGPipeContextCallCount = 65; -inline constexpr SizeT kMGPipeCallCount = 76; +inline constexpr SizeT kMGPipeScreenCallCount = 12; +inline constexpr SizeT kMGPipeContextCallCount = 66; +inline constexpr SizeT kMGPipeCallCount = 78; // A table that is not exactly its call count of function pointers has grown a // member that no generator knows about. diff --git a/MobileGL/MG_Pipe/generated/PipeThunks.inc b/MobileGL/MG_Pipe/generated/PipeThunks.inc index f5930223..81740bed 100644 --- a/MobileGL/MG_Pipe/generated/PipeThunks.inc +++ b/MobileGL/MG_Pipe/generated/PipeThunks.inc @@ -320,3 +320,11 @@ inline void MGP_SetStorageBlockBinding(const MGPStorageBlockBinding* payload, co inline void MGP_CopyFramebufferToTexture(const MGPCopyFromFramebuffer* payload) { gMGPipeContext.CopyFramebufferToTexture(payload); } + +inline void MGP_ApplierReset(const MGPApplierReset* payload) { + gMGPipeScreen.ApplierReset(payload); +} + +inline void MGP_ObjectDeath(const MGPHandleOnly* payload) { + gMGPipeContext.ObjectDeath(payload); +} diff --git a/MobileGL/MG_Pipe/generated/PipeVerify.inc b/MobileGL/MG_Pipe/generated/PipeVerify.inc index d94b9c1f..2ebe220e 100644 --- a/MobileGL/MG_Pipe/generated/PipeVerify.inc +++ b/MobileGL/MG_Pipe/generated/PipeVerify.inc @@ -112,6 +112,7 @@ inline Bool MGPipeVerify(const MGPPatchParameter& a, const MGPPatchParameter& b, inline Bool MGPipeVerify(const MGPStreamOutputBind& a, const MGPStreamOutputBind& b, const char** outField); inline Bool MGPipeVerify(const MGPStorageBlockBinding& a, const MGPStorageBlockBinding& b, const char** outField); inline Bool MGPipeVerify(const MGPCopyFromFramebuffer& a, const MGPCopyFromFramebuffer& b, const char** outField); +inline Bool MGPipeVerify(const MGPApplierReset& a, const MGPApplierReset& b, const char** outField); inline Bool MGPipeVerify(const RenderStateParameters& a, const RenderStateParameters& b, const char** outField); inline Bool MGPipeVerify(const PixelStoreParameters& a, const PixelStoreParameters& b, const char** outField); inline Bool MGPipeVerify(const SamplerParameters& a, const SamplerParameters& b, const char** outField); @@ -259,6 +260,8 @@ struct MGPipeHasFieldVerifier : std::true_type {}; template <> struct MGPipeHasFieldVerifier : std::true_type {}; template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> struct MGPipeHasFieldVerifier : std::true_type {}; template <> struct MGPipeHasFieldVerifier : std::true_type {}; @@ -662,6 +665,11 @@ inline Bool MGPipeVerify(const MGPCopyFromFramebuffer& a, const MGPCopyFromFrame return true; } +inline Bool MGPipeVerify(const MGPApplierReset& a, const MGPApplierReset& b, const char** outField) { + MGP_FIELDS_MGPApplierReset(MGP_VERIFY_FIELD) + return true; +} + inline Bool MGPipeVerify(const RenderStateParameters& a, const RenderStateParameters& b, const char** outField) { MGP_FIELDS_RenderStateParameters(MGP_VERIFY_FIELD) return true; @@ -709,4 +717,4 @@ inline Bool MGPipeVerify(const MGPVertexBindingPointWire& a, const MGPVertexBind #undef MGP_VERIFY_FIELD -inline constexpr SizeT kMGPipeVerifiedPayloadCount = 77; +inline constexpr SizeT kMGPipeVerifiedPayloadCount = 78; diff --git a/MobileGL/MG_Pipe/generated/PipeWire.inc b/MobileGL/MG_Pipe/generated/PipeWire.inc index 80e66755..f5faaa2c 100644 --- a/MobileGL/MG_Pipe/generated/PipeWire.inc +++ b/MobileGL/MG_Pipe/generated/PipeWire.inc @@ -146,7 +146,9 @@ enum class MGPWireOp : Uint16 { BindStreamOutput = 74, SetStorageBlockBinding = 75, CopyFramebufferToTexture = 76, - kOpCount = 77, + ApplierReset = 77, + ObjectDeath = 78, + kOpCount = 79, }; // THE FLAGS, EXPORTED ONCE, INDEXED BY OPCODE (P5 R-13.4). MGPWireRecHeader::Flags is @@ -244,6 +246,8 @@ inline constexpr Uint32 kMGPipeCallFlags[static_cast(MGPWireOp::kOpCount) /* 74 BindStreamOutput */ static_cast(kNone), /* 75 SetStorageBlockBinding */ static_cast(kHasBlob), /* 76 CopyFramebufferToTexture*/ static_cast(kNone), + /* 77 ApplierReset */ static_cast(kNone), + /* 78 ObjectDeath */ static_cast(kNone), }; static_assert(sizeof(kMGPipeCallFlags) / sizeof(kMGPipeCallFlags[0]) == static_cast(MGPWireOp::kOpCount), @@ -905,6 +909,22 @@ static_assert(sizeof(MGPWireRec_CopyFramebufferToTexture) == ((sizeof(MGPWireRecHeader) + sizeof(MGPCopyFromFramebuffer) + 7u) & ~SizeT(7u)), "MGPWireRec_CopyFramebufferToTexture gained padding; the wire format moved"); +struct alignas(8) MGPWireRec_ApplierReset { + MGPWireRecHeader Header; + MGPApplierReset Payload; +}; +static_assert(sizeof(MGPWireRec_ApplierReset) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPApplierReset) + 7u) & ~SizeT(7u)), + "MGPWireRec_ApplierReset gained padding; the wire format moved"); + +struct alignas(8) MGPWireRec_ObjectDeath { + MGPWireRecHeader Header; + MGPHandleOnly Payload; +}; +static_assert(sizeof(MGPWireRec_ObjectDeath) == + ((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)), + "MGPWireRec_ObjectDeath gained padding; the wire format moved"); + [[noreturn]] inline void MGPipeWireProtocolFatal(const char* call, Uint64 size, Uint64 remaining) { MGLOG_F("MGPipe: protocol corruption applying %s: size=%llu remaining=%llu", call, static_cast(size), static_cast(remaining)); @@ -1182,6 +1202,12 @@ inline Bool MGPipeApplyWireRecord(MGPWireOp op, const void* record, Uint64 size, case MGPWireOp::CopyFramebufferToTexture: MGP_WIRE_CHECK_BOUNDS(MGPWireRec_CopyFramebufferToTexture, "CopyFramebufferToTexture"); break; + case MGPWireOp::ApplierReset: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_ApplierReset, "ApplierReset"); + break; + case MGPWireOp::ObjectDeath: + MGP_WIRE_CHECK_BOUNDS(MGPWireRec_ObjectDeath, "ObjectDeath"); + break; case MGPWireOp::kInvalid: case MGPWireOp::kOpCount: default: diff --git a/MobileGL/MG_Remote/Client/WireTables.cpp b/MobileGL/MG_Remote/Client/WireTables.cpp index bc4459d7..fbd9c557 100644 --- a/MobileGL/MG_Remote/Client/WireTables.cpp +++ b/MobileGL/MG_Remote/Client/WireTables.cpp @@ -38,6 +38,10 @@ #include #include +// P5c (ct): EmitObjectDeathRecord resolves the dying object's handle in the CLIENT's own +// allocator - a client surface, asked on the client thread, which is the one place the lookup +// is legal under rule E (CONTRACT-P5C.md §5.2). +#include #include #include @@ -503,6 +507,81 @@ namespace MobileGL::MG_Remote::Client { } // namespace + // ===================================================================================== + // P5c (ct), CONTRACT-P5C.md §5: the two control records' producers + // ===================================================================================== + + namespace { + // The ContextSerial of the NEXT applier_reset record. NOWHERE IN THE FRONTEND NUMBERS + // A MAKE-CURRENT: the tracker knows the EDGE (FreshlyPrimed) and no session or context + // carries a serial for it (checked at the contract commit), so the serial is this + // client's own count of applier_reset emissions - 0 for the first make-current, one + // more per primed edge. That is the only value the server can hold the client to in a + // one-context session: the sink keeps the same count and ASSERTS equality + // (CONTRACT-P5C.md §1: the value is asserted, never dispatched on; P6's multi-context + // shape is what will read it for real). GL-thread only, like every emitter here. + Uint64 g_applierResetContextSerial = 0; + } // namespace + + Bool EmitApplierResetRecord() { + // NO SESSION, NO RECORD - and false rather than the RequireSession Fatal, because a + // validate can legitimately prime with a transport CONFIGURED but no live session: + // the bring-up window before ClientSession::Start() (§6 layer 2's documented + // exception), and the server-role-only shape a ServerLoop fixture drives with + // Transport=InProcess and no client at all. In both this process has no wire for the + // reset to cross, and the caller answers with the direct call the record replaced. + ClientSession* session = ClientSession::Active(); + if (session == nullptr || !session->Started()) return false; + if (g_clientTablesUninstalled.load(std::memory_order_acquire)) return false; + MG_Pipe::MGPApplierReset record{}; + record.ContextSerial = g_applierResetContextSerial++; + session->EmitAndWait(MGPWireOp::ApplierReset, &record, sizeof(record), nullptr, 0, nullptr, + 0, nullptr); + ++g_emitted; + return true; + } + + ObjectDeathEmit EmitObjectDeathRecord(MG_Pipe::MGPipeKind kind, Uint64 lifetimeId) { + using MG_Pipe::MGPipeHandle; + + // NO SESSION, NO RECORD - and that is NOT the RequireSession shape on purpose. A death + // notice can outlive the session by construction: frontend objects die at context + // teardown and at process exit, after Stop has joined the apply thread and freed the + // rings, and the twins this record would kill died with the server. Emitting into that + // window is a use-after-free; Fatal-ing on it is an abort at exit() for a legal death. + // NoSession is its own answer (not folded into NoHandle) because the caller's fallback + // for "no wire exists" is delivery to the server loop this process still has, while + // "no handle" means there is nothing to deliver at all. + ClientSession* session = ClientSession::Active(); + if (session == nullptr || !session->Started()) return ObjectDeathEmit::NoSession; + // The same window, one step earlier: Stop has raised the teardown refusal but not yet + // joined the apply thread. A death landing here is destructor-driven, not an app bug, + // and the twin it would kill dies with the backend Stop is destroying - so it is + // skipped, where a routed call in the same window is Fatal{ClientTablesUninstalled}. + if (g_clientTablesUninstalled.load(std::memory_order_acquire)) { + return ObjectDeathEmit::NoSession; + } + + // THE CLIENT'S OWN ALLOCATOR, ON THE CLIENT'S OWN THREAD (§5.2 step 1). A lifetime id + // the allocator cannot resolve names an object that never crossed - no create record + // ever carried its handle, so the server has no twin to kill and NOTHING is emitted. + // This replaces the mailbox's unconditional delivery, which hopped every death to the + // apply thread whether or not the server had ever seen the object. + const MGPipeHandle handle = MG_Pipe::MGPipeSlots().FindByLifetimeId(kind, lifetimeId); + if (MG_Pipe::MGPipeHandleIsNull(handle)) return ObjectDeathEmit::NoHandle; + + MG_Pipe::MGPHandleOnly record{}; + record.Handle = handle; + record.Kind = static_cast(kind); + // The WAIT is the only property the blocking mailbox hop provided and the only one P5c + // keeps (§5.2 step 3): it orders the death against in-flight verbs that name the + // handle, so the server's twin cannot be released underneath a verb that is using it. + session->EmitAndWait(MGPWireOp::ObjectDeath, &record, sizeof(record), nullptr, 0, nullptr, + 0, nullptr); + ++g_emitted; + return ObjectDeathEmit::Emitted; + } + void RequireClientTablesInstalled(const char* row) { if (g_clientTablesUninstalled.load(std::memory_order_acquire)) { MGLOG_F("MGPipe: Fatal{ClientTablesUninstalled, \"%s\"} - the client tables are " diff --git a/MobileGL/MG_Remote/Client/WireTables.h b/MobileGL/MG_Remote/Client/WireTables.h index 4413e355..ffffe0de 100644 --- a/MobileGL/MG_Remote/Client/WireTables.h +++ b/MobileGL/MG_Remote/Client/WireTables.h @@ -35,6 +35,8 @@ #pragma once #include +#include + #if MOBILEGL_BUILD_DISAGGREGATED namespace MobileGL::MG_Remote::Client { @@ -86,6 +88,33 @@ namespace MobileGL::MG_Remote::Client { // server otherwise). Declared here so PipeFill does not have to include a server header. Bool RunsAsTheServerRole(); + // ---- P5c (ct), CONTRACT-P5C.md §5: the two control records' client halves -------------- + // + // NOT ROUTED ROWS - the two are appended to PipeCalls.def with no MGPipeApply* entry point + // and no table slot at all, so their producers call these directly. PipeFill.cpp's + // FreshlyPrimed arm calls the first; DirectGLES' OnFrontendStateObjectDestroyed calls the + // second. + + // applier_reset (§5.1). Emitted on the GL thread at the tracker.FreshlyPrimed() edge, + // BEFORE the block's client-side resets, so the record's barrier orders the server's + // MGPipeApplierReset() against every verb that follows. ContextSerial is the client's own + // count of applier_reset emissions (0 = the first make-current): nothing in the frontend + // numbers a make-current today, and the sink asserts the value against its own count of + // the same records rather than dispatching on it (P5c: one context per session; P6 reads + // it for real). Returns false - nothing emitted - when no live session could carry the + // record (the bring-up window before Start, a server-role-only fixture, teardown); the + // caller then makes the direct call the record replaced. + Bool EmitApplierResetRecord(); + + // The three answers a death notice can produce (§5.2): the record crossed; the client's + // own allocator cannot resolve the dying object (the server never saw it, so there is no + // twin to kill and NOTHING crossed); or no live session could carry the record. The + // third is NOT folded into the second: a server role with no wire at all - a ServerLoop + // fixture's shape, never a real split's - still has a server loop the caller delivers to, + // where a handle that never existed has nothing to deliver. + enum class ObjectDeathEmit : Uint8 { Emitted, NoHandle, NoSession }; + ObjectDeathEmit EmitObjectDeathRecord(MG_Pipe::MGPipeKind kind, Uint64 lifetimeId); + } // namespace MobileGL::MG_Remote::Client #endif // MOBILEGL_BUILD_DISAGGREGATED diff --git a/MobileGL/MG_Remote/Server/PipeApplier.cpp b/MobileGL/MG_Remote/Server/PipeApplier.cpp index dcfc3783..a625f080 100644 --- a/MobileGL/MG_Remote/Server/PipeApplier.cpp +++ b/MobileGL/MG_Remote/Server/PipeApplier.cpp @@ -14,6 +14,10 @@ #include #include +// P5c ct: object_death's per-kind release names the Espryt twin tables (CONTRACT-P5C.md +// §5.2). The same dependency ServerLoop.cpp already takes for CreateBackend; a server built +// on Magma simply holds no twins in these tables and every release resolves to nothing. +#include #include #include #include @@ -889,6 +893,67 @@ namespace MobileGL::MG_Remote::Server { return true; } + // ---- P5c ct (MG_Remote/CONTRACT-P5C.md §5) ------------------------------------------ + // + // TWO CONTROL RECORDS, NO BACKEND TABLE AND NO DECLINE ARM. Neither body consults + // Table(): the reset belongs to the applier this process owns, and the death release + // belongs to the twin tables - a backend that registered no slots (Magma's XFB shape) + // still has an applier to reset and still answers a death with the same generation + // check. A record that cannot be proved is Fatal, not declined: both refusals are + // ProtocolCorruption because by the time the sink runs the codec has already proved the + // record's SHAPE, and what is left to check are the contract facts about the peer (§1). + + Bool ServerVerbSink::OnApplierReset(const MG_Pipe::MGPApplierReset& reset) { + // §1: the serial is ASSERTED, never dispatched on. P5c has exactly one context per + // session, so the only legal sequence is 0, 1, 2, ... and the session's own count of + // accepted resets IS the expected value; anything else means the two ends disagree + // about how many make-current edges have crossed, which no backend answer can fix. + if (reset.ContextSerial != m_applierResetSerial) { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"ApplierReset.ContextSerial\"} - the " + "record carries %llu and this session has accepted %llu reset(s); the " + "serial is asserted against the session's own count, not dispatched on " + "(one context per session in P5c)", + static_cast(reset.ContextSerial), + static_cast(m_applierResetSerial)); + std::abort(); + } + ++m_applierResetSerial; + // THE WHOLE POINT OF THE RECORD: the reset runs HERE, on the apply thread, against + // the g_applier this role owns (PipeApply.cpp:409). The layer-2 guard inside + // MGPipeApplierReset passes because this IS the apply thread; the GL-thread direct + // call it replaced is the Fatal arm. + MG_Pipe::MGPipeApplierReset(); + ++m_applierResets; + return true; + } + + Bool ServerVerbSink::OnObjectDeath(const MG_Pipe::MGPHandleOnly& death) { + // §1's zero ruling: a null handle means "the object never crossed", and the client + // emits NOTHING in that case (§5.2) - so a null handle arriving here is corruption, + // not a no-op. + if (MG_Pipe::MGPipeHandleIsNull(death.Handle)) { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"ObjectDeath.Handle\"} - a null " + "handle never crosses: the client emits nothing for an object its own " + "allocator cannot resolve (CONTRACT-P5C.md §5.2)"); + std::abort(); + } + if (death.Kind >= static_cast(MG_Pipe::MGPipeKind::KindCount)) { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"ObjectDeath.Kind\"} - %u is not an " + "MGPipeKind", + static_cast(death.Kind)); + std::abort(); + } + // The per-kind release, keyed by the handle the record carried. A false answer is + // NOT a decline: the kind's own delete opcode may already have released the twin + // (the idempotent second path every notice arm documents), and a kind this backend + // does not twin (Buffer, whose death crosses as resource_destroy) legally resolves + // to nothing. + MG_Backend::DirectGLES::ReleaseTwinsForWireObjectDeath( + death.Handle, static_cast(death.Kind)); + ++m_objectDeaths; + return true; + } + // ----------------------------------------------------------------------------------- // PipeApplier // ----------------------------------------------------------------------------------- diff --git a/MobileGL/MG_Remote/Server/PipeApplier.h b/MobileGL/MG_Remote/Server/PipeApplier.h index 4c407586..e8c3d3e1 100644 --- a/MobileGL/MG_Remote/Server/PipeApplier.h +++ b/MobileGL/MG_Remote/Server/PipeApplier.h @@ -150,6 +150,32 @@ namespace MobileGL::MG_Remote::Server { Bool OnGenerateMipmap(const MG_Pipe::MGPMipPlan& plan) override; Bool OnCopyFramebufferToTexture(const MG_Pipe::MGPCopyFromFramebuffer& copy) override; + // ---- P5c ct (MG_Remote/CONTRACT-P5C.md §5): the two control records ---------------- + // + // REAL BODIES FROM THE DAY THE ROWS EXIST, not the P5b stub shape: the rows were + // appended by the same package that lands these bodies, so there is no window in + // which a client can emit ahead of its server half. + // + // OnApplierReset (§5.1): the make-current edge's server half. ContextSerial is + // ASSERTED against this session's own count of applier_reset records - one context + // per session in P5c, so the legal sequence is 0, 1, 2, ... - and only then is + // MGPipeApplierReset() run, here, on the apply thread that owns g_applier. + // OnObjectDeath (§5.2): a null handle never crosses (the client emits nothing for an + // object its allocator cannot resolve), so one arriving is + // Fatal{ProtocolCorruption, "ObjectDeath.Handle"}; otherwise the per-kind release + // runs by the record's handle (SlotTables.h's ReleaseTwinByHandle). + Bool OnApplierReset(const MG_Pipe::MGPApplierReset& reset) override; + Bool OnObjectDeath(const MG_Pipe::MGPHandleOnly& death) override; + + // P5c ct's tallies, for the same reason every other row's tally exists (R-16: a probe + // may not arm against a stub). ApplierResets counts the records ACCEPTED (serial + // checked, reset run); ObjectDeaths counts every record the sink dispatched. + Uint64 ApplierResets() const { return m_applierResets; } + Uint64 ObjectDeaths() const { return m_objectDeaths; } + // The ContextSerial the NEXT applier_reset record must carry. Exposed so a case can + // assert the sequence rather than only the count. + Uint64 ExpectedApplierResetSerial() const { return m_applierResetSerial; } + // Per-verb tallies. The lane asserts these moved, because "the scenario passed" on a // split build is also what a scenario that ran entirely on the monolith path looks // like (R-16: a probe may not arm against a stub). @@ -231,6 +257,14 @@ namespace MobileGL::MG_Remote::Server { Uint64 m_streamOutputControls = 0; Uint64 m_streamOutputBinds = 0; Uint64 m_patchParameters = 0; + // P5c ct. m_applierResetSerial is BOTH the expected ContextSerial of the next record + // and the count of accepted resets: the two are one number because the serial is the + // session's own count of applier_reset records (CONTRACT-P5C.md §1: asserted, never + // dispatched on). m_objectDeaths counts records dispatched, released twin or not - + // the idempotent second path answering "nothing held it" is a legal record. + Uint64 m_applierResetSerial = 0; + Uint64 m_applierResets = 0; + Uint64 m_objectDeaths = 0; // ReadPixels' destination. The pixels go into the reply slot, but GLFunctionsTable:: // ReadPixels writes into a caller buffer, so one staging vector per session sits // between them. Grown, never shrunk, and never handed out past the call. diff --git a/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp b/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp index e44c2a36..82f76a52 100644 --- a/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp +++ b/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp @@ -239,7 +239,9 @@ namespace MobileGL::MG_Remote::Wire { X(PatchParameter, MGPPatchParameter) \ X(BindStreamOutput, MGPStreamOutputBind) \ X(SetStorageBlockBinding, MGPStorageBlockBinding) \ - X(CopyFramebufferToTexture, MGPCopyFromFramebuffer) + X(CopyFramebufferToTexture, MGPCopyFromFramebuffer) \ + X(ApplierReset, MGPApplierReset) \ + X(ObjectDeath, MGPHandleOnly) namespace { @@ -2085,6 +2087,20 @@ namespace MobileGL::MG_Remote::Wire { m_verbs->OnCopyFramebufferToTexture( *static_cast(payload)); + // ---- P5c's two control records, opcodes 77..78 (MG_Remote/CONTRACT-P5C.md §5) -------- + // + // Fixed-size PODs the bounds gate has already proved; neither carries a blob, a tail + // or a reply, so each arm hands over and stops. ObjectDeath's null-handle refusal is + // the sink's, not the codec's: the codec proves SHAPES, and "the client emits nothing + // for an object that never crossed" (§5.2) is a contract fact about the peer. + case MGPWireOp::ApplierReset: + return m_verbs != nullptr && + m_verbs->OnApplierReset(*static_cast(payload)); + + case MGPWireOp::ObjectDeath: + return m_verbs != nullptr && + m_verbs->OnObjectDeath(*static_cast(payload)); + case MGPWireOp::SetSwapInterval: // Class C, wave 3 (census-classC.md "static cross"); not a verb (FillPoints.def:21). return false; diff --git a/MobileGL/MG_Remote/Wire/PipeWireCodec.h b/MobileGL/MG_Remote/Wire/PipeWireCodec.h index 00137595..5c3eb9c2 100644 --- a/MobileGL/MG_Remote/Wire/PipeWireCodec.h +++ b/MobileGL/MG_Remote/Wire/PipeWireCodec.h @@ -534,6 +534,25 @@ namespace MobileGL::MG_Remote::Wire { (void)copy; return false; } + + // ---- P5c (MG_Remote/CONTRACT-P5C.md §5): the two control records, opcodes 77..78. + // + // Same hand-over shape as the P5b rows: the codec validates the record (a fixed-size + // POD, no blob, no tail) and the SERVER'S sink does the work - OnApplierReset runs the + // server's own MGPipeApplierReset() after asserting ContextSerial against the + // session's (§5.1: ASSERTED, never dispatched on, P5c has one context per session), + // and OnObjectDeath releases the kind's twin table by the handle the record carried + // (§5.2). The default bodies return false ("this build does not implement it"), so a + // unit decoder without a server declines by the same answer every other unimplemented + // row gives. + virtual Bool OnApplierReset(const MG_Pipe::MGPApplierReset& reset) { + (void)reset; + return false; + } + virtual Bool OnObjectDeath(const MG_Pipe::MGPHandleOnly& death) { + (void)death; + return false; + } }; // Not thread safe: one decoder on the apply thread, by construction. diff --git a/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp b/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp index 6188d943..0d034e2c 100644 --- a/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp +++ b/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp @@ -125,11 +125,13 @@ TEST(PipeCatalogue, GeneratedTablesHoldTheWholeCatalogue) { EXPECT_EQ(kMGPipeContextCallCount, kMGPipeCallCount - ClassCount()); // The per-class counts PipeCalls.def documents in its header. - EXPECT_EQ(ClassCount(), 11u); + // kScreen is 11 + P5c's applier_reset (MG_Remote/CONTRACT-P5C.md §5.1); kCtxObject is + // 9 + P5c's object_death (§5.2), the framebuffer family's first wire delete opcode. + EXPECT_EQ(ClassCount(), 12u); EXPECT_EQ(ClassCount(), 8u); EXPECT_EQ(ClassCount(), 13u); EXPECT_EQ(ClassCount(), 17u); - EXPECT_EQ(ClassCount(), 9u); + EXPECT_EQ(ClassCount(), 10u); // 13 + the five P5b-appended verbs (MG_Remote/CONTRACT-P5B.md): bind_shader_image, // patch_parameter, bind_stream_output, set_storage_block_binding, // copy_framebuffer_to_texture. @@ -139,8 +141,9 @@ TEST(PipeCatalogue, GeneratedTablesHoldTheWholeCatalogue) { // A row nobody has migrated is null - which is exactly what "this subsystem has not been // migrated, keep pulling" means (plan B section 4.1). // -// UNTIL P5 R-17 THAT WAS EVERY ROW, and this case said so. It is now EXACTLY THE 34 ROWS WITH -// NO MGPipeApply* ENTRY POINT: the other 37 have an applier, R-17 installs adapters over them, +// UNTIL P5 R-17 THAT WAS EVERY ROW, and this case said so. It is now EXACTLY THE 41 ROWS WITH +// NO MGPipeApply* ENTRY POINT (78 - the 37 that have one; the number was 34 at P5, 39 after +// P5b's five sink-only verbs): the other 37 have an applier, R-17 installs adapters over them, // and a null there would no longer mean "keep pulling" - `MG_Impl/Pipe`'s call sites go through // the thunks, so a null would mean "call through a null pointer". The number is asserted rather // than the emptiness, because "37 installed" and "34 still null" are the two halves of a @@ -156,10 +159,12 @@ TEST(PipeCatalogue, UninstalledTablesAreAllNull) { const void* const* context = reinterpret_cast(&gMGPipeContext); #if MOBILEGL_PIPE_PUSH MGPipeInstallMonolithTables(); - // The 34 rows with no MGPipeApply* entry point are still null, and null still means "this + // The 41 rows with no MGPipeApply* entry point are still null, and null still means "this // subsystem has not been migrated, keep pulling". Named rather than counted, because the // count is the other case's job and two cases asserting the same number would both go red - // for one change. + // for one change. P5c's two control records are among them by design (CONTRACT-P5C.md §5: + // no MGPipeApply* entry point, no monolith producer - under a transport they reach + // WireVerbSink instead). EXPECT_EQ(gMGPipeContext.SetShaderBuffers, nullptr); EXPECT_EQ(gMGPipeContext.SetStreamOutputTargets, nullptr); EXPECT_EQ(gMGPipeContext.DrawVbo, nullptr); @@ -168,6 +173,8 @@ TEST(PipeCatalogue, UninstalledTablesAreAllNull) { EXPECT_EQ(gMGPipeScreen.GetCaps, nullptr); EXPECT_EQ(gMGPipeContext.QueryCreate, nullptr); EXPECT_EQ(gMGPipeScreen.FenceCreate, nullptr); + EXPECT_EQ(gMGPipeScreen.ApplierReset, nullptr); + EXPECT_EQ(gMGPipeContext.ObjectDeath, nullptr); #else // A pull build compiles no applier and no routing, so the pre-migration statement is the // whole truth there and this case is the one that says so. @@ -235,6 +242,12 @@ TEST(PipeCatalogue, ExactlyTheRoutedRowsAreInstalledAndTheRestAreStillNull) { EXPECT_EQ(gMGPipeContext.Present, nullptr); EXPECT_EQ(gMGPipeContext.SetSwapInterval, nullptr); EXPECT_EQ(gMGPipeScreen.GetCaps, nullptr); + // P5c's two control records (CONTRACT-P5C.md §5) take the same answer for a different + // reason: no MGPipeApply* exists for either and none may be installed - under a transport + // they cross to WireVerbSink, under monolith the GL thread's direct call and the death + // notice's mailbox are the producers, byte for byte as before (G1/G2). + EXPECT_EQ(gMGPipeScreen.ApplierReset, nullptr); + EXPECT_EQ(gMGPipeContext.ObjectDeath, nullptr); #else // A pull build compiles no applier and no routing, so the pre-migration statement is still // the whole truth there. @@ -557,7 +570,15 @@ TEST(PipeCatalogue, LateArrivalsAreAppendedWithoutRenumbering) { EXPECT_EQ(static_cast(MGPWireOp::BindStreamOutput), 74); EXPECT_EQ(static_cast(MGPWireOp::SetStorageBlockBinding), 75); EXPECT_EQ(static_cast(MGPWireOp::CopyFramebufferToTexture), 76); - EXPECT_EQ(static_cast(MGPWireOp::kOpCount), 77); + // P5c (MG_Remote/CONTRACT-P5C.md §5) appended the two control records AFTER P5b's five, by + // the same rule: opcodes 77..78, and nothing before them moved. applier_reset is a kScreen + // row (a make-current is a whole-server edge, exactly as FenceWaitServer is a screen call) + // and object_death a kCtxObject one; both carry no blob, no reply and no tail. + EXPECT_EQ(static_cast(MGPWireOp::ApplierReset), 77); + EXPECT_EQ(static_cast(MGPWireOp::ObjectDeath), 78); + EXPECT_EQ(static_cast(MGPWireOp::kOpCount), 79); + EXPECT_EQ(MGPipeCallFlagsFor(MGPWireOp::ApplierReset), static_cast(kNone)); + EXPECT_EQ(MGPipeCallFlagsFor(MGPWireOp::ObjectDeath), static_cast(kNone)); // And the P5b rows carry what their contract says: one blob (the block name) and nothing // else, and the extended draw row keeps its two flags. EXPECT_EQ(MGPipeCallFlagsFor(MGPWireOp::SetStorageBlockBinding), static_cast(kHasBlob)); @@ -575,6 +596,11 @@ TEST(PipeCatalogue, LateArrivalsAreAppendedWithoutRenumbering) { EXPECT_EQ(sizeof(MGPCopyFromFramebuffer), 48u); EXPECT_EQ(sizeof(MGPCopyRegion), 72u); EXPECT_EQ(sizeof(MGPDrawIndirect), 40u); + // The P5c payload, pinned like every other MGP_ASSERT_POD at runtime: one Uint64, no + // padding. object_death REUSES MGPHandleOnly (CONTRACT-P5C.md §1), so there is no second + // struct to pin - the 16 bytes are pinned above with the handle family. + EXPECT_EQ(sizeof(MGPApplierReset), 8u); + EXPECT_EQ(sizeof(MGPHandleOnly), 16u); // The two draw-flag bits P5b's d1 arms are exclusive by contract and distinct by value. EXPECT_EQ(static_cast(kDrawIsIndirect), 1u << 5); EXPECT_EQ(static_cast(kDrawHasUserIndices) & static_cast(kDrawIsIndirect), 0u); @@ -771,8 +797,10 @@ TEST(PipeCatalogue, FloatVectorsCompareBitwise) { TEST(PipeCatalogue, SixValueStructsHaveFieldLists) { // 72 through P5; P5b appended five call payloads (MG_Remote/CONTRACT-P5B.md: MGPImageBind, // MGPPatchParameter, MGPStreamOutputBind, MGPStorageBlockBinding, MGPCopyFromFramebuffer), - // each with its own field list, so the comparator sees every one of them: 77. - EXPECT_EQ(kMGPipeVerifiedPayloadCount, 77u); + // each with its own field list, so the comparator sees every one of them: 77. P5c appended + // applier_reset's MGPApplierReset (CONTRACT-P5C.md §5.1) - object_death reuses + // MGPHandleOnly, which has had a list since P0 - so: 78. + EXPECT_EQ(kMGPipeVerifiedPayloadCount, 78u); static_assert(MGPipeHasFieldVerifier::value); static_assert(MGPipeHasFieldVerifier::value); static_assert(MGPipeHasFieldVerifier::value); diff --git a/MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp b/MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp index aaa95ff4..1c36d721 100644 --- a/MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp +++ b/MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp @@ -38,6 +38,10 @@ #include #include #include +// P5c ct: object_death's round trip releases REAL Espryt twin-table entries, so the suite +// drives the same registries the sink dispatches to (Managers.h). A suite that substituted a +// mock here would pin the dispatch and nothing about the release (R-16). +#include #include #include #include @@ -268,6 +272,16 @@ namespace { FramebufferCopies.push_back(copy); return true; } + // ---- P5c's rows (CONTRACT-P5C.md §5): the two control records, recorded the same + // way so a round trip asserts the arm and the record intact. + Bool OnApplierReset(const MGPApplierReset& reset) override { + ApplierResets.push_back(reset); + return true; + } + Bool OnObjectDeath(const MGPHandleOnly& death) override { + ObjectDeaths.push_back(death); + return true; + } std::vector Clears; std::vector Blits; std::vector Presents; @@ -293,6 +307,8 @@ namespace { std::vector Patches; std::vector MipPlans; std::vector FramebufferCopies; + std::vector ApplierResets; + std::vector ObjectDeaths; }; Replies& Answers() { return m_replies; } @@ -1243,6 +1259,73 @@ TEST_F(PipeWireCodecTest, BindStreamOutputReachesTheSink) { EXPECT_EQ(wire.Sink().StreamOutputBinds[0].LifetimeId, 0x1234567890ull); } +// ===================================================================================== +// P5c ct (MG_Remote/CONTRACT-P5C.md §5): the two control records round-trip +// ===================================================================================== +// +// Same shape as the P5b rows above: encode, pump, and assert the record reached the RIGHT +// sink method intact. What a serial ASSERT or a twin release does with the record is the +// SERVER sink's layer (SessionTest drives that); here the codec proves the bytes and the +// dispatch. + +TEST_F(PipeWireCodecTest, ApplierResetReachesTheSinkWithItsSerial) { + Wire2 wire; + MGPApplierReset reset{}; + reset.ContextSerial = 0; // 0 = the first make-current (CONTRACT-P5C.md §1) + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::ApplierReset, &reset, sizeof(reset)), + kInvalidSeq); + bool applied = false; + ASSERT_TRUE(wire.PumpOne(&applied)); + EXPECT_TRUE(applied); + ASSERT_EQ(wire.Sink().ApplierResets.size(), 1u); + EXPECT_EQ(wire.Sink().ApplierResets[0].ContextSerial, 0u); + + // A second edge carries the next serial, and the two records arrive in order. + reset.ContextSerial = 1; + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::ApplierReset, &reset, sizeof(reset)), + kInvalidSeq); + ASSERT_TRUE(wire.PumpOne(&applied)); + EXPECT_TRUE(applied); + ASSERT_EQ(wire.Sink().ApplierResets.size(), 2u); + EXPECT_EQ(wire.Sink().ApplierResets[1].ContextSerial, 1u); + EXPECT_EQ(wire.Decoder().AppliedSeq(), 2u); +} + +TEST_F(PipeWireCodecTest, ObjectDeathReachesTheSinkWithItsHandleAndKind) { + Wire2 wire; + // One record per kind the death switch handles (CONTRACT-P5C.md §1: one payload for all + // seven), because the kind is what the sink dispatches on and a row that widened it + // wrong would drop every death of that kind. + const MGPipeKind kinds[] = { + MGPipeKind::Texture, MGPipeKind::Framebuffer, MGPipeKind::Renderbuffer, + MGPipeKind::SamplerCso, MGPipeKind::ShaderCso, MGPipeKind::SamplerViewCso, + MGPipeKind::VertexElementsCso, + }; + Uint32 slot = 40; + for (MGPipeKind kind : kinds) { + MGPHandleOnly death = HandleOnly(slot, kind); + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::ObjectDeath, &death, sizeof(death)), + kInvalidSeq) + << static_cast(kind); + ++slot; + } + bool applied = false; + constexpr SizeT kKindCount = sizeof(kinds) / sizeof(kinds[0]); + for (SizeT i = 0; i < kKindCount; ++i) { + ASSERT_TRUE(wire.PumpOne(&applied)) << i; + EXPECT_TRUE(applied) << i; + } + ASSERT_EQ(wire.Sink().ObjectDeaths.size(), kKindCount); + slot = 40; + for (SizeT i = 0; i < kKindCount; ++i) { + EXPECT_EQ(wire.Sink().ObjectDeaths[i].Handle.Slot, slot) << i; + EXPECT_EQ(wire.Sink().ObjectDeaths[i].Handle.Gen, 1u) << i; + EXPECT_EQ(wire.Sink().ObjectDeaths[i].Kind, static_cast(kinds[i])) << i; + ++slot; + } + EXPECT_EQ(wire.Decoder().AppliedSeq(), static_cast(kKindCount)); +} + // ===================================================================================== // P5b t2 (MG_Remote/CONTRACT-P5B.md §2 t2). c0b's cases above round-trip each row once; these // pin the fields t2's EMITTERS actually fill and the one ordering property the span family has. @@ -2893,3 +2976,185 @@ TEST(FenceWireRoundTrip, MissingConsumerDeclinesWithoutInventingASignaledAnswer) EXPECT_EQ(wire.Answers().All[0].Status, ReplySink::kStatusDeclined); EXPECT_TRUE(wire.Answers().All[0].Bytes.empty()); } + + +// ===================================================================================== +// P5c ct (MG_Remote/CONTRACT-P5C.md §5): the two control records, through the REAL sink +// ===================================================================================== +// +// The codec cases above pin the bytes and the dispatch with a recording sink; these pin what +// the SERVER's sink does with the record: the serial assert, the applier reset that actually +// runs, the twin release that actually retires the table entry, and the layer-2 guard that +// turns the reverted GL-thread direct call red. + +TEST(ApplierResetWireRoundTrip, TheSerialSequenceIsAssertedAndTheServerResetRuns) { + Wire2 wire; + Server::ServerVerbSink sink; + wire.Decoder().SetVerbSink(&sink); + auto send = [&](Uint64 serial) { + const MGPApplierReset reset{serial}; + EXPECT_NE(wire.Encoder().EncodeRecord(MGPWireOp::ApplierReset, &reset, sizeof(reset)), + kInvalidSeq); + bool applied = false; + EXPECT_TRUE(wire.PumpOne(&applied)); + EXPECT_TRUE(applied); + }; + + // The reset RUNS - asserted on the applier's own state, not on the tally (R-16: a probe + // may not arm against a stub, and a tally a stub could also move is a stub's witness). + // BoundVertexElements is one of the fields MGPipeApplierReset clears (PipeApply.cpp). + MGPipeApplier().BoundVertexElements = MakeHandle(77); + send(0); // 0 = the first make-current (CONTRACT-P5C.md §1) + EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundVertexElements)); + EXPECT_EQ(sink.ApplierResets(), 1u); + EXPECT_EQ(sink.ExpectedApplierResetSerial(), 1u); + + // The second edge carries the next serial and is accepted in order. + MGPipeApplier().BoundVertexElements = MakeHandle(78); + send(1); + EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundVertexElements)); + EXPECT_EQ(sink.ApplierResets(), 2u); + EXPECT_EQ(sink.ExpectedApplierResetSerial(), 2u); +} + +TEST(ObjectDeathWireRoundTrip, TheTextureAndFramebufferTwinsReleaseByHandleAndNeverByAStaleGeneration) { + Wire2 wire; + Server::ServerVerbSink sink; + wire.Decoder().SetVerbSink(&sink); + namespace gles = MG_Backend::DirectGLES; + + // The handle arm answers only when kMGPipeSubsystemEsprytSlots is set, and a unit binary + // never runs ConfigLoader: Features.PipePush is 0 here, not the shipping default + // (kMGPipeSubsystemsMigratedAtP4a). Set the bit the way SanityTest's pipe fixtures do; + // the arm verdict latches on first use and this case is the first use in its process. + const Uint64 savedPush = MG_Config::Features.PipePush; + MG_Config::Features.PipePush = savedPush | kMGPipeSubsystemEsprytSlots; + struct RestorePush { + Uint64 bits; + ~RestorePush() { MG_Config::Features.PipePush = bits; } + } restore{savedPush}; + + // Texture AND Framebuffer - the two kinds R-16 names for the death-recycle path. The + // others share the one dispatch and the one table walk, so two kinds pin the shape: one + // whose death also crosses as resource_destroy (Texture: the idempotent second path), + // and the kind whose FIRST wire delete opcode object_death is (Framebuffer). + struct KindCase { + MGPipeKind kind; + Uint32 slot; + }; + const KindCase cases[] = {{MGPipeKind::Texture, 901}, {MGPipeKind::Framebuffer, 902}}; + for (const KindCase& one : cases) { + const auto getOrCreate = [&](MGPipeHandle h) -> void* { + if (one.kind == MGPipeKind::Texture) { + return gles::TextureImpl::g_backendTextureObjects.GetOrCreateByHandle(h); + } + return gles::FramebufferImpl::g_backendFramebufferObjects.GetOrCreateByHandle(h); + }; + const auto liveGen = [&](Uint32 slot) -> Uint32 { + if (one.kind == MGPipeKind::Texture) { + return gles::TextureImpl::g_backendTextureObjects.LiveGenAt(slot); + } + return gles::FramebufferImpl::g_backendFramebufferObjects.LiveGenAt(slot); + }; + ASSERT_NE(getOrCreate(MakeHandle(one.slot, 1)), nullptr) + << "the Espryt handle arm did not go live with kMGPipeSubsystemEsprytSlots set; " + "the verdict latched elsewhere in this process"; + ASSERT_EQ(liveGen(one.slot), 1u) << static_cast(one.kind); + + // The death crosses and the twin retires: the slot's live generation goes back to 0. + const Uint64 deathsBefore = sink.ObjectDeaths(); + const MGPHandleOnly death{MakeHandle(one.slot, 1), static_cast(one.kind), 0}; + EXPECT_NE(wire.Encoder().EncodeRecord(MGPWireOp::ObjectDeath, &death, sizeof(death)), + kInvalidSeq); + bool applied = false; + EXPECT_TRUE(wire.PumpOne(&applied)); + EXPECT_TRUE(applied); + EXPECT_EQ(liveGen(one.slot), 0u) << static_cast(one.kind); + EXPECT_EQ(sink.ObjectDeaths(), deathsBefore + 1u); + + // THE ABA HALF (LiveGenAt's semantics): the slot is recycled forward to a NEW object + // at generation 2, and the dead object's handle - replayed, as a duplicated or + // reordered record would replay it - must not answer for the new object. The record + // is still APPLIED (idempotency is legal; a generation mismatch is a no-op), but the + // new twin survives it. + ASSERT_NE(getOrCreate(MakeHandle(one.slot, 2)), nullptr); + ASSERT_EQ(liveGen(one.slot), 2u); + EXPECT_NE(wire.Encoder().EncodeRecord(MGPWireOp::ObjectDeath, &death, sizeof(death)), + kInvalidSeq); + ASSERT_TRUE(wire.PumpOne(&applied)); + EXPECT_TRUE(applied); + EXPECT_EQ(liveGen(one.slot), 2u) + << "a stale handle released the recycled slot's new twin (kind " + << static_cast(one.kind) << ")"; + + // And the new object's own death retires it, leaving the table as the case found it. + const MGPHandleOnly newDeath{MakeHandle(one.slot, 2), static_cast(one.kind), 0}; + EXPECT_NE(wire.Encoder().EncodeRecord(MGPWireOp::ObjectDeath, &newDeath, sizeof(newDeath)), + kInvalidSeq); + ASSERT_TRUE(wire.PumpOne(&applied)); + EXPECT_TRUE(applied); + EXPECT_EQ(liveGen(one.slot), 0u); + } +} + +#if MGTEST_HAVE_FORK + +// The three Fatal arms, driven through the encoder and the REAL sink in a forked child (the +// file's rule: never EXPECT_DEATH here - it re-runs the whole binary and re-enters the +// applier's globals). + +TEST(CtWireFatals, ASerialTheSessionCannotProveIsProtocolCorruption) { + const ChildResult r = RunInChild([] { + Wire2 wire; + Server::ServerVerbSink sink; + wire.Decoder().SetVerbSink(&sink); + // The FIRST reset a session sees must carry serial 0 (one context, the count is the + // session's own); leading with 1 is a sequence the session cannot prove. + const MGPApplierReset reset{1}; + (void)wire.Encoder().EncodeRecord(MGPWireOp::ApplierReset, &reset, sizeof(reset)); + bool applied = false; + (void)wire.PumpOne(&applied); + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("Fatal{ProtocolCorruption, \"ApplierReset.ContextSerial\"}"), + std::string::npos) + << r.Log; +} + +TEST(CtWireFatals, ANullDeathHandleIsProtocolCorruption) { + const ChildResult r = RunInChild([] { + Wire2 wire; + Server::ServerVerbSink sink; + wire.Decoder().SetVerbSink(&sink); + // §1's zero ruling: a null handle means "the object never crossed", and the client + // emits NOTHING then - so a null handle ON the wire is corruption, not a no-op. + const MGPHandleOnly death{kMGPipeNullHandle, static_cast(MGPipeKind::Texture), 0}; + (void)wire.Encoder().EncodeRecord(MGPWireOp::ObjectDeath, &death, sizeof(death)); + bool applied = false; + (void)wire.PumpOne(&applied); + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("Fatal{ProtocolCorruption, \"ObjectDeath.Handle\"}"), std::string::npos) + << r.Log; +} + +// THE GUARD'S TWO NON-FATAL ARMS, as unit controls. The Fatal arm itself needs a LIVE client +// session (the guard deliberately exempts the configured-but-wireless window - §6 layer 2's +// documented bring-up exception), which a unit binary has no handshake for; that arm is the +// integration lane's CtWireScenario.TheDirectApplierResetCallOnTheGLThreadIsRoleViolation, +// and the manual revert of PipeFill's FreshlyPrimed arm is the red-once beside it. What is +// pinnable here is that neither monolith nor a wireless transport is stopped. +TEST(CtWireFatals, TheDirectApplierResetCallSurvivesMonolithAndAWirelessTransport) { + const ChildResult wireless = RunInChild([] { + // Transport=InProcess with NO client session - the ServerLoop fixture's shape. The + // direct call is the only reset that exists there, so it must NOT be stopped. + MG_Config::Transport = MG_Config::TransportMode::InProcess; + MGPipeApplierReset(); + }); + EXPECT_FALSE(DiedOfAbort(wireless)) << DescribeStatus(wireless) << "\n" << wireless.Log; + + const ChildResult monolith = RunInChild([] { MGPipeApplierReset(); }); + EXPECT_FALSE(DiedOfAbort(monolith)) << DescribeStatus(monolith) << "\n" << monolith.Log; +} + +#endif // MGTEST_HAVE_FORK