From 930ad0193f1b75d3cbe0eb5f03bf8ea76742f91d Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Thu, 17 Sep 2026 03:55:30 -0400 Subject: [PATCH] [MG_Remote, MG_Impl, MG_Backend] (Disaggregated): P5c ev - SEG_EVENT is the only reverse channel The three reverse MGPipeCallbacks entries become the SERVER session's producer callbacks (Reserve + fill the head + copy the payload inline + PublishEvents; the host pointer never reaches the wire), installed at Accept and uninstalled at Close, a second installation over a claimed entry being Fatal{RoleViolation, callback-double-install}. Producers wired: buffer writeback (the mapped bytes are copied into the record's inline tail instead of a raw pointer in MGPBlobRef::Offset), GPU-written (Magma's three direct MarkGpuWritten bypasses are routed through the callback), surface-changed (the backend posts MGPSurfaceInfo; the client consumer replays the allocate/format writes against pDefaultFramebufferInfo on the GL thread, where R3's ownership always was), and kEventGlError = 4 for PipeInputs::RecordError (ordering stays P9's). PipeInputs::InvalidateCompileEnv's forward is deleted with an active transport - R-12's caps re-publication already does its job. The consumer's monolith guard is replaced by the three segment arms (kSegEvent resolves through the session's SegmentTable; kMGHostSpanSegNone is monolith-only and Fatal{ProtocolCorruption} on the wire; anything else the same), and DrainEventRing calls the client consumers BY NAME, never through the global table. Overflow is Fatal{EventRingOverflow} - P5c's events are lossless, P9 owns the drop policy. Monolith arms keep the pre-P5c expressions character for character. Evidence: unit 2139/2139 (incl. the new kEventGlError round-trip), integration-split 107/107, monolith GPU subset 24/24; negative control executed - a writeback blobref reverted to the raw-pointer shape turns DirectGLES.Split.AtomicCounterScenario. SubDataAfterDispatchSurvivesAnImmediateReadback red with Fatal{ProtocolCorruption, OnBufferWriteback.Seg}. CONTRACT-P5C sections 1, 4. --- MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp | 23 ++- .../DirectVulkan/Renderer/SwapchainObject.cpp | 58 ++++-- .../DirectVulkan/Renderer/UniformManager.cpp | 16 ++ .../DirectVulkan/Renderer/VulkanRenderer.cpp | 10 + MobileGL/MG_Impl/Pipe/PipeFill.cpp | 38 ++++ MobileGL/MG_Impl/Pipe/ResourceTracker.h | 149 +++++++++++++-- MobileGL/MG_Remote/Client/ClientSession.cpp | 164 +++++++++++++---- MobileGL/MG_Remote/Client/ClientSession.h | 5 + MobileGL/MG_Remote/Server/ServerSession.cpp | 174 ++++++++++++++++++ MobileGL/MG_Remote/Server/ServerSession.h | 12 ++ MobileGL/MG_Remote/Transport/EventRing.h | 19 +- MobileGL/MG_Test/Wire/SessionTest.cpp | 49 +++++ 12 files changed, 646 insertions(+), 71 deletions(-) diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index d589a76f..d1877864 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -20,6 +20,8 @@ #if MOBILEGL_PIPE_PUSH // P3a: the applier's vertex-input records the re-keyed draw-buffer memo is validated against. #include +// P5c ev: the surface-changed event's producer callback, installed by the server session. +#include #endif #include #include @@ -11601,8 +11603,25 @@ namespace MobileGL::MG_Backend::DirectGLES { auto* depthTexture = defaultFBOInfo->depthAttachment.get(); auto* stencilTexture = defaultFBOInfo->stencilAttachment.get(); - if (depthTexture) depthTexture->SetInternalFormat(depthFormat); - if (stencilTexture) stencilTexture->SetInternalFormat(stencilFormat); +#if MOBILEGL_PIPE_PUSH + if (MG_Pipe::gMGPipeCallbacks.OnSurfaceChanged != nullptr) { + // P5c ev (CONTRACT-P5C §4.2): with an active transport the attachments are CLIENT + // memory and this thread may not write them - the backend fills MGPSurfaceInfo + // and posts, and the client applies it to its own pDefaultFramebufferInfo on the + // GL thread. FORMAT ONLY, exactly as the monolith arm below: Width/Height stay 0, + // which is the consumer's cue to leave the placeholder extent untouched. The + // callback's presence IS the transport probe - the server session installs it at + // Accept, and under monolith nobody ever does. + MG_Pipe::MGPSurfaceInfo info{}; + info.InternalFormat = static_cast(depthFormat); + info.IsDefault = 1; + MG_Pipe::gMGPipeCallbacks.OnSurfaceChanged(&info); + } else +#endif + { + if (depthTexture) depthTexture->SetInternalFormat(depthFormat); + if (stencilTexture) stencilTexture->SetInternalFormat(stencilFormat); + } MGLOG_D("DirectGLES: default framebuffer depth=%d stencil=%d float=%d; published attachment " "formats depth=%d stencil=%d", depthBits, stencilBits, floatDepth ? 1 : 0, static_cast(depthFormat), diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/SwapchainObject.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/SwapchainObject.cpp index a2743a16..f06bb9f1 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/SwapchainObject.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/SwapchainObject.cpp @@ -11,6 +11,11 @@ #include "MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.h" #include "MG_State/GLState/TextureState/TextureObject2D.h" +#if MOBILEGL_PIPE_PUSH +// P5c ev: the surface-changed event's producer callback, installed by the server session. +#include +#endif + #if defined(__has_include) #if __has_include() #include @@ -280,11 +285,6 @@ namespace MobileGL::MG_Backend::DirectVulkan { static_cast(defaultFramebufferExtent.width) * static_cast(defaultFramebufferExtent.height) * 4; - auto* colorTex = static_cast(defaultFBOInfo->colorAttachment.get()); - colorTex->AllocateStorage( - TextureUploadTarget::Texture2D, 0, { - {extentWidth, extentHeight, 1}, - defaultAttachmentByteSize}); // TODO: 4 is format size TextureInternalFormat depthFormat = TextureInternalFormat::Depth24Stencil8; switch (m_depthStencilFormat) { case VK_FORMAT_D24_UNORM_S8_UINT: @@ -300,11 +300,6 @@ namespace MobileGL::MG_Backend::DirectVulkan { depthFormat = TextureInternalFormat::Depth24Stencil8; break; } - auto* depthTex = static_cast(defaultFBOInfo->depthAttachment.get()); - depthTex->SetInternalFormat(depthFormat); - depthTex->AllocateStorage(TextureUploadTarget::Texture2D, 0, { - {extentWidth, extentHeight, 1}, - defaultAttachmentByteSize}); // TODO: 4 is format size // The default FBO's stencil attachment must track the swapchain extent: // FramebufferObject::CheckCompleteness requires every valid attachment @@ -324,11 +319,44 @@ namespace MobileGL::MG_Backend::DirectVulkan { stencilFormat = depthFormat; break; } - auto* stencilTex = static_cast(defaultFBOInfo->stencilAttachment.get()); - stencilTex->SetInternalFormat(stencilFormat); - stencilTex->AllocateStorage(TextureUploadTarget::Texture2D, 0, { - {extentWidth, extentHeight, 1}, - defaultAttachmentByteSize}); // TODO: 4 is format size + +#if MOBILEGL_PIPE_PUSH + if (MG_Pipe::gMGPipeCallbacks.OnSurfaceChanged != nullptr) { + // P5c ev (CONTRACT-P5C §4.2): with an active transport the default FBO's + // attachments are CLIENT memory and this thread may not write them - the backend + // fills MGPSurfaceInfo and posts, and the client's consumer replays exactly the + // allocate/format writes of the monolith arm below on the GL thread. The two + // formats are always equal by the switches above, so one InternalFormat carries + // both. The callback's presence IS the transport probe - the server session + // installs it at Accept, and under monolith nobody ever does. + MG_Pipe::MGPSurfaceInfo info{}; + info.Width = defaultFramebufferExtent.width; + info.Height = defaultFramebufferExtent.height; + info.InternalFormat = static_cast(depthFormat); + info.Samples = 1; + info.Layers = 1; + info.IsDefault = 1; + MG_Pipe::gMGPipeCallbacks.OnSurfaceChanged(&info); + } else +#endif + { + auto* colorTex = static_cast(defaultFBOInfo->colorAttachment.get()); + colorTex->AllocateStorage( + TextureUploadTarget::Texture2D, 0, { + {extentWidth, extentHeight, 1}, + defaultAttachmentByteSize}); // TODO: 4 is format size + auto* depthTex = static_cast(defaultFBOInfo->depthAttachment.get()); + depthTex->SetInternalFormat(depthFormat); + depthTex->AllocateStorage(TextureUploadTarget::Texture2D, 0, { + {extentWidth, extentHeight, 1}, + defaultAttachmentByteSize}); // TODO: 4 is format size + + auto* stencilTex = static_cast(defaultFBOInfo->stencilAttachment.get()); + stencilTex->SetInternalFormat(stencilFormat); + stencilTex->AllocateStorage(TextureUploadTarget::Texture2D, 0, { + {extentWidth, extentHeight, 1}, + defaultAttachmentByteSize}); // TODO: 4 is format size + } } diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp index 19094359..668c1264 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp @@ -11,6 +11,10 @@ #include "MG_Backend/DirectVulkan/DirectVulkanResourceState.h" #include "MG_State/GLState/Core.h" #include +#if MOBILEGL_PIPE_PUSH +// P5c ev: the GPU-write announcement routes through the reverse channel (R2). +#include +#endif #include "MG_State/GLState/ProgramState/ProgramObject.h" #include "MG_State/GLState/TextureState/TextureObject1D.h" #include "MG_State/GLState/TextureState/TextureObject2D.h" @@ -1072,7 +1076,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { // changed a byte of it. bufferObject->EnsureGpuResidentStorage(); if (imageBinding.Access != GL_READ_ONLY) { +#if MOBILEGL_PIPE_PUSH + // P5c ev (R2, CONTRACT-P5C §4.2): the announcement goes through the reverse + // channel - the apply thread may not poke the client object directly. + MG_Pipe::MGPipeAnnounceBufferGpuWritten(bufferObject); +#else bufferObject->MarkGpuWritten(); +#endif } BufferSlice slice{}; @@ -1228,7 +1238,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { // results are visible without a readback path, exactly as for a capture buffer. bufferObject->EnsureGpuResidentStorage(); // ... and the read that follows has to wait for this draw or dispatch to retire. +#if MOBILEGL_PIPE_PUSH + // P5c ev (R2, CONTRACT-P5C §4.2): through the reverse channel, not a direct poke of + // the client object from the apply thread. + MG_Pipe::MGPipeAnnounceBufferGpuWritten(bufferObject); +#else bufferObject->MarkGpuWritten(); +#endif BufferSlice slice{}; if (!m_bufferManager->AcquireResidentSlice(BufferKind::ShaderStorage, bufferObject, slice) || !slice.IsValid()) { diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index 7cc2f3e0..92b551f6 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -15,6 +15,10 @@ #include "MG_State/GLState/Core.h" #include +#if MOBILEGL_PIPE_PUSH +// P5c ev: the GPU-write announcement routes through the reverse channel (R2). +#include +#endif #include "MG_State/GLState/ProgramState/ProgramObject.h" #include "MG_State/GLState/ProgramState/ShaderObject.h" #include "MG_State/GLState/SamplerState/SamplerObject.h" @@ -11615,7 +11619,13 @@ void main() { // have happened, so the buffer is also flagged for the wait that a later CPU // read has to perform - the capture is a GPU write like any shader's. bufferObject->EnsureGpuResidentStorage(); +#if MOBILEGL_PIPE_PUSH + // P5c ev (R2, CONTRACT-P5C §4.2): through the reverse channel, not a direct poke + // of the client object from the apply thread. + MG_Pipe::MGPipeAnnounceBufferGpuWritten(bufferObject); +#else bufferObject->MarkGpuWritten(); +#endif BufferSlice slice{}; if (!m_bufferManager.AcquireResidentSlice(BufferKind::Vertex, bufferObject, slice)) { MGLOG_E_ONCE("BeginXfbCaptureForDraw: failed to acquire capture buffer %zu", i); diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp index d874a0f5..dd359989 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.cpp +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -48,6 +48,9 @@ // lanes must keep answering exactly what they answered before. #include #include +// P5c ev (CONTRACT-P5C §4.2): RecordError's transport arm posts kEventGlError through the +// server session, and InvalidateCompileEnv's forward is deleted with an active transport. +#include #endif #include @@ -662,7 +665,18 @@ namespace MobileGL::MG_Pipe { // the resource subsystem bit would make the vertex-input subsystem emit null handles // in exactly the A/B arm that exists to isolate the two. It costs one free-list pop // and one map insert per buffer object and emits nothing. + // + // The client resource callbacks are MONOLITH-ONLY (CONTRACT-P5C §4.1): with an + // active transport the server session installs its producer callbacks at Accept and + // the client's consumers are invoked by name from DrainEventRing, so installing + // them here would be the double installation the check now aborts on. +#if MOBILEGL_BUILD_DISAGGREGATED + if (MG_Config::Transport == MG_Config::TransportMode::Monolith) { + MGPipeInstallClientResourceCallbacks(); + } +#else MGPipeInstallClientResourceCallbacks(); +#endif MGPipeResourceTrackerInstance().Acquire(buffer); } @@ -1752,6 +1766,16 @@ namespace MobileGL::MG_Pipe { void PipeInputs::InvalidateCompileEnv() { MGP_STICKY_FORWARD_PULL(InvalidateCompileEnv); +#if MOBILEGL_BUILD_DISAGGREGATED + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + // DELETED with an active transport (CONTRACT-P5C §4.2): R-12's caps + // re-publication already invalidates the CLIENT's compile environment when the + // second snapshot arrives (CapsMirror.cpp:78-80), and this forward is a write + // into the frontend with no wire shape. A caller that still needs it under a + // transport is a defect to fix, not a pull to serve. + return; + } +#endif if (auto* ctx = LiveContext()) ctx->InvalidateCompileEnv(); } @@ -1763,6 +1787,20 @@ namespace MobileGL::MG_Pipe { void PipeInputs::RecordError(ErrorCode code, UniquePtr info) { MGP_STICKY_FORWARD_PULL(RecordError); +#if MOBILEGL_BUILD_DISAGGREGATED + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + // The error queue is CLIENT state and the apply thread may not write it (R4). + // kEventGlError carries the code and the message; the client records it into its + // own queue at the next drain point. ORDERING IS P9's (CONTRACT-P5C §4.2): the + // event is observed at the next EmitAndWait drain, which preserves per-thread + // program order of error-then-read but not cross-verb interleaving - the + // accepted P5c shape, stated in the contract rather than discovered in P6. + const String message = info != nullptr ? info->toString() : String{}; + MG_Remote::Server::ServerSessionInstance().PostGlError(static_cast(code), + message.c_str()); + return; + } +#endif auto* ctx = LiveContext(); if (ctx == nullptr) { MGLOG_E_ONCE("PipeInputs::RecordError: no live context, dropping error %d", static_cast(code)); diff --git a/MobileGL/MG_Impl/Pipe/ResourceTracker.h b/MobileGL/MG_Impl/Pipe/ResourceTracker.h index cb0bd654..4da14a39 100644 --- a/MobileGL/MG_Impl/Pipe/ResourceTracker.h +++ b/MobileGL/MG_Impl/Pipe/ResourceTracker.h @@ -49,7 +49,16 @@ #include +#if MOBILEGL_BUILD_DISAGGREGATED +// P5c ev (CONTRACT-P5C §4.3): the writeback consumer's SEG_EVENT arm resolves the blobref +// through the client session's own SegmentTable, and the transport check below is the same +// MG_Config::Transport probe PipeFill.cpp uses. Behind the build option for G1's reason - +// nothing under MG_Remote may be reachable from a pull build. +#include +#endif + #include +#include namespace MobileGL::MG_Pipe { @@ -550,6 +559,19 @@ namespace MobileGL::MG_Pipe { // The client resolves the handle to its own object and writes the shadow; the epoch bump // stays SERVER-side and happens AFTER this returns, never before (ARCHITECTURE.md 7.4: // the reverse channel needs the same ordering guarantee as the forward one). + // + // THE BLOBREF'S THREE ARMS (CONTRACT-P5C §4.3, replacing the P3a monolith guard that + // rejected every Seg != kMGHostSpanSegNone and would have dropped every writeback EVENT + // on arrival): + // Seg == kSegEvent -> the wire shape (CONTRACT-P5C §1's reverse-channel row): + // Offset is the byte offset of the inline payload inside + // SEG_EVENT, resolved through the client session's OWN + // SegmentTable, bounds-checked against the announced size; + // Seg == kMGHostSpanSegNone -> monolith only: Offset IS the backend's mapped address + // (MGPipeTypes.h says so in as many words). With an active + // transport this is rule B in the reverse direction and is + // Fatal{ProtocolCorruption, "OnBufferWriteback.Seg"}; + // anything else -> the same Fatal. inline void MGPipeClientOnBufferWriteback(MGPipeHandle res, Uint64 offset, MGPBlobRef bytes) { auto* buffer = MGPipeResourceTrackerInstance().Resolve(res); if (buffer == nullptr) { @@ -557,18 +579,62 @@ namespace MobileGL::MG_Pipe { res.Slot, res.Gen); return; } - if (bytes.Seg != kMGHostSpanSegNone) { - MGLOG_E_ONCE("MGPipe: OnBufferWriteback carried a transport segment (%u); P3a is monolith only", + void* bytePtr = nullptr; + if (bytes.Seg == kMGHostSpanSegNone) { +#if MOBILEGL_BUILD_DISAGGREGATED + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"OnBufferWriteback.Seg\"} - a " + "writeback blobref carried Seg = kMGHostSpanSegNone (a raw host " + "address) with an active transport. Rule B binds the reverse " + "direction exactly as it binds MGHostSpan: on the wire the blobref " + "names SEG_EVENT and an in-segment offset, never a host address"); + std::abort(); + } +#endif + bytePtr = reinterpret_cast(static_cast(bytes.Offset)); + } +#if MOBILEGL_BUILD_DISAGGREGATED + else if (bytes.Seg == MG_Remote::Wire::kSegEvent) { + // Size == 0 with a live resource is legal - a zero-length writeback + // (CONTRACT-P5C §1) - and SegmentTable::Resolve answers nullptr for a zero size, + // so only a non-empty run goes through the bounds-checked resolve. + if (bytes.Size != 0) { + auto* session = MG_Remote::Client::ClientSession::Active(); + const void* resolved = + session == nullptr + ? nullptr + : session->Segments().Resolve(MG_Remote::Wire::kSegEvent, bytes.Offset, + bytes.Size); + if (resolved == nullptr) { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"OnBufferWriteback.Offset\"} - " + "the writeback blobref's {%llu + %llu} does not resolve inside the " + "session's SEG_EVENT segment", + static_cast(bytes.Offset), + static_cast(bytes.Size)); + std::abort(); + } + bytePtr = const_cast(resolved); + } + } else { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"OnBufferWriteback.Seg\"} - a writeback " + "blobref carried Seg %u, which is neither kMGHostSpanSegNone (monolith) nor " + "kSegEvent (the wire shape)", + bytes.Seg); + std::abort(); + } +#else + else { + // The pre-P5c guard's monolith spelling, kept for builds with no transport layer: + // a segment tag cannot legitimately arrive here and the write must not be made + // from a null pointer. + MGLOG_E_ONCE("MGPipe: OnBufferWriteback carried a transport segment (%u) in a build " + "with no transport; the writeback is dropped", bytes.Seg); return; } - // Monolith: Seg is kMGHostSpanSegNone and Offset IS the address of the backend's - // mapped bytes (MGPipeTypes.h says so in as many words). Under a transport the - // segment resolves first, and that is the phase's edit, not this one's. - buffer->WritebackFromBackend( - DataPtr{reinterpret_cast(static_cast(bytes.Offset)), - static_cast(bytes.Size)}, - static_cast(offset)); +#endif + buffer->WritebackFromBackend(DataPtr{bytePtr, static_cast(bytes.Size)}, + static_cast(offset)); } // A draw or dispatch wrote these ranges. ARCHITECTURE.md 7.1 calls this a NARROWING @@ -600,16 +666,67 @@ namespace MobileGL::MG_Pipe { buffer->MarkGpuWritten(); } - // Installed once, and never over an entry a backend already claimed: these two are the - // CLIENT's implementations of a backend -> frontend callback, so the backend installs - // the rest of the table and these two answer for it. + // MONOLITH ONLY (CONTRACT-P5C §4.1): with an active transport the three reverse entries + // of gMGPipeCallbacks are the SERVER session's producer callbacks and the client + // consumers are invoked BY NAME from DrainEventRing - the global table is a + // producer-side surface under split. The caller (MGPipeMintResourceHandle) gates on the + // resolved transport, exactly as PipeFill.cpp's other transport arms do. + // + // Installed over only an EMPTY or OUR OWN entry: writing over one somebody else claimed + // is Fatal{RoleViolation, "callback-double-install"} - the "never over an entry a + // backend already claimed" comment, made a check. Finding our own function is the + // idempotent repeat this helper runs once per buffer mint, not a second installation. inline void MGPipeInstallClientResourceCallbacks() { - if (gMGPipeCallbacks.OnBufferWriteback == nullptr) { - gMGPipeCallbacks.OnBufferWriteback = &MGPipeClientOnBufferWriteback; - } + const auto install = [](auto& entry, auto* fn, const char* name) { + if (entry != nullptr && entry != fn) { + MGLOG_F("MGPipe: Fatal{RoleViolation, \"callback-double-install\"} - %s is " + "already claimed by a different function. With an active transport " + "the reverse entries are the server session's producers; the client " + "installs them under monolith only", + name); + std::abort(); + } + entry = fn; + }; + install(gMGPipeCallbacks.OnBufferWriteback, &MGPipeClientOnBufferWriteback, + "OnBufferWriteback"); + install(gMGPipeCallbacks.OnGpuWritten, &MGPipeClientOnGpuWritten, "OnGpuWritten"); + } + + // The GPU-write announcement for a buffer the backend holds only a FRONTEND POINTER to - + // Magma's barrier-pulled binding-point reads (UniformManager / VulkanRenderer), whose + // direct bufferObject->MarkGpuWritten() was the apply thread poking client memory (R2). + // Routed through the reverse channel exactly as DirectGLES' MarkBufferGpuWritten routes + // its own sites: ONE whole-buffer range, stated rather than implied (zero ranges is the + // shape a fully narrowed announcement will legitimately have at P8/P9, and the two must + // not be the same record). The handle comes from the client allocator's lifetime-id + // probe - the mint is unconditional at the BufferObject constructor + // (MGPipeMintResourceHandle), so a live buffer always has one. + // + // The fallback is the monolith shape, preserved byte for byte: no reverse channel + // installed (a pull-arm build, or a process with no session) pokes the object directly, + // which is exactly what these sites did before. A MISSING HANDLE with a live channel is + // loud rather than a silent drop, the same choice MarkBufferGpuWritten makes. + inline void MGPipeAnnounceBufferGpuWritten( + const SharedPtr& bufferObject) { + if (bufferObject == nullptr) return; if (gMGPipeCallbacks.OnGpuWritten == nullptr) { - gMGPipeCallbacks.OnGpuWritten = &MGPipeClientOnGpuWritten; + bufferObject->MarkGpuWritten(); + return; } + const MGPipeHandle res = + MGPipeSlots().FindByLifetimeId(MGPipeKind::Buffer, bufferObject->GetLifetimeId()); + if (MGPipeHandleIsNull(res)) { + MGLOG_E_ONCE("MGPipe: no handle for the GPU-write announcement of buffer %u - the " + "reverse channel is installed but the mint is missing, so the mark " + "would be dropped at the consumer; poking the object directly rather " + "than losing it", + bufferObject->GetExternalIndex()); + bufferObject->MarkGpuWritten(); + return; + } + const MGPRange whole{0, kMGPipeWholeBuffer}; + gMGPipeCallbacks.OnGpuWritten(res, 1, &whole); } } // namespace MobileGL::MG_Pipe #endif // MOBILEGL_PIPE_PUSH diff --git a/MobileGL/MG_Remote/Client/ClientSession.cpp b/MobileGL/MG_Remote/Client/ClientSession.cpp index e69fe173..dedca57d 100644 --- a/MobileGL/MG_Remote/Client/ClientSession.cpp +++ b/MobileGL/MG_Remote/Client/ClientSession.cpp @@ -19,7 +19,12 @@ #include "WireTables.h" #include +#include +#include #include +#include +#include +#include #include #include @@ -173,6 +178,58 @@ namespace MobileGL::MG_Remote::Client { // THE BYTES LIVE IN THE RING ITSELF, so Drained() is called only after every payload // pointer popped here has been consumed: retiring earlier is R-11's violation one level // down (EventRing.h:168-171 says so in as many words). + // + // THE CONSUMERS ARE CALLED BY NAME, NEVER THROUGH gMGPipeCallbacks (CONTRACT-P5C + // §4.1): with an active transport the global table's reverse entries are the SERVER + // session's producer callbacks, so routing the drain through it would call the + // producer back from the GL thread - the layer-2 violation the contract names. + // + // THE SURFACE-CHANGED CONSUMER IS WHERE R3's OWNERSHIP ALWAYS WAS: the backend + // posts the MGPSurfaceInfo, and the allocate/format writes against + // pDefaultFramebufferInfo happen HERE, on the GL thread, against client memory. + void ApplySurfaceChangedToClient(const MG_Pipe::MGPSurfaceInfo& info) { + auto& defaultFBOInfo = MG_Impl::GLImpl::FramebufferImpl::pDefaultFramebufferInfo; + if (!defaultFBOInfo) return; + const auto format = static_cast(info.InternalFormat); + auto* colorTex = + static_cast(defaultFBOInfo->colorAttachment.get()); + auto* depthTex = + static_cast(defaultFBOInfo->depthAttachment.get()); + auto* stencilTex = + static_cast(defaultFBOInfo->stencilAttachment.get()); + if (info.Width != 0 && info.Height != 0) { + // The SWAPCHAIN's publication shape: an extent-carrying event. Reproduces + // SwapchainObject's monolith writes statement for statement - colour storage + // at the extent, depth/stencil format then storage - because + // FramebufferObject::CheckCompleteness requires every attachment to agree. + const Int extentWidth = static_cast(info.Width); + const Int extentHeight = static_cast(info.Height); + const SizeT attachmentByteSize = + static_cast(info.Width) * static_cast(info.Height) * 4; + if (colorTex != nullptr) { + colorTex->AllocateStorage(TextureUploadTarget::Texture2D, 0, + {{extentWidth, extentHeight, 1}, attachmentByteSize}); + } + if (depthTex != nullptr) { + depthTex->SetInternalFormat(format); + depthTex->AllocateStorage(TextureUploadTarget::Texture2D, 0, + {{extentWidth, extentHeight, 1}, attachmentByteSize}); + } + if (stencilTex != nullptr) { + stencilTex->SetInternalFormat(format); + stencilTex->AllocateStorage(TextureUploadTarget::Texture2D, 0, + {{extentWidth, extentHeight, 1}, attachmentByteSize}); + } + } else { + // The DirectGLES publication shape: FORMAT ONLY, Width/Height == 0. The + // placeholder's 512x512 extent is deliberately left alone - all three + // attachments share it, and resizing depth/stencil without colour would + // report the default framebuffer incomplete. + if (depthTex != nullptr) depthTex->SetInternalFormat(format); + if (stencilTex != nullptr) stencilTex->SetInternalFormat(format); + } + } + Uint32 DrainEventRing(Transport::EventRingConsumer& events) { if (!events.Valid()) return 0; Uint32 delivered = 0; @@ -197,19 +254,18 @@ namespace MobileGL::MG_Remote::Client { static_cast(view.payloadSize - sizeof(*head))); std::abort(); } - if (MG_Pipe::gMGPipeCallbacks.OnBufferWriteback != nullptr) { - // The blobref names SEG_EVENT and the IN-SEGMENT offset of those inline - // bytes - never a host address (R-2's rule B), which is the whole reason - // EventRingConsumer exposes OffsetInSegment at all. - MG_Pipe::MGPBlobRef blob{}; - blob.Seg = Wire::kSegEvent; - blob.Offset = events.OffsetInSegment(bytes); - blob.Size = head->Size; - MG_Pipe::gMGPipeCallbacks.OnBufferWriteback( - MG_Pipe::MGPipeHandle{head->Resource.Slot, head->Resource.Gen}, - head->Offset, blob); - ++delivered; - } + // The blobref names SEG_EVENT and the IN-SEGMENT offset of those inline + // bytes - never a host address (R-2's rule B), which is the whole reason + // EventRingConsumer exposes OffsetInSegment at all. The consumer's + // kSegEvent arm resolves it through this session's own SegmentTable. + MG_Pipe::MGPBlobRef blob{}; + blob.Seg = Wire::kSegEvent; + blob.Offset = events.OffsetInSegment(bytes); + blob.Size = head->Size; + MG_Pipe::MGPipeClientOnBufferWriteback( + MG_Pipe::MGPipeHandle{head->Resource.Slot, head->Resource.Gen}, + head->Offset, blob); + ++delivered; break; } case Transport::kEventGpuWritten: { @@ -224,39 +280,71 @@ namespace MobileGL::MG_Remote::Client { static_cast(tail)); std::abort(); } - if (MG_Pipe::gMGPipeCallbacks.OnGpuWritten != nullptr) { - // EventRange and MGPRange are the same two Uint64s (EventRing.h:61-66 - // asserts it), so the tail is handed over as-is rather than copied into - // a second array a later reader could get out of step with. - const auto* ranges = reinterpret_cast( - static_cast(view.payload) + sizeof(*head)); - MG_Pipe::gMGPipeCallbacks.OnGpuWritten( - MG_Pipe::MGPipeHandle{head->Resource.Slot, head->Resource.Gen}, - static_cast(head->RangeCount), ranges); - ++delivered; - } + // EventRange and MGPRange are the same two Uint64s (EventRing.h:61-66 + // asserts it), so the tail is handed over as-is rather than copied into + // a second array a later reader could get out of step with. + const auto* ranges = reinterpret_cast( + static_cast(view.payload) + sizeof(*head)); + MG_Pipe::MGPipeClientOnGpuWritten( + MG_Pipe::MGPipeHandle{head->Resource.Slot, head->Resource.Gen}, + static_cast(head->RangeCount), ranges); + ++delivered; break; } case Transport::kEventSurfaceChanged: { if (view.payloadSize < sizeof(Transport::EventSurfaceChangedHead)) break; - if (MG_Pipe::gMGPipeCallbacks.OnSurfaceChanged != nullptr) { - const auto* head = - static_cast(view.payload); - MG_Pipe::MGPSurfaceInfo info{}; - info.Width = head->Width; - info.Height = head->Height; - info.InternalFormat = head->InternalFormat; - info.Samples = head->Samples; - info.Layers = head->Layers; - info.IsDefault = head->IsDefault; - MG_Pipe::gMGPipeCallbacks.OnSurfaceChanged(&info); - ++delivered; + const auto* head = + static_cast(view.payload); + MG_Pipe::MGPSurfaceInfo info{}; + info.Width = head->Width; + info.Height = head->Height; + info.InternalFormat = head->InternalFormat; + info.Samples = head->Samples; + info.Layers = head->Layers; + info.IsDefault = head->IsDefault; + ApplySurfaceChangedToClient(info); + ++delivered; + break; + } + case Transport::kEventGlError: { + if (view.payloadSize < sizeof(Transport::EventGlErrorHead)) break; + const auto* head = + static_cast(view.payload); + const char* message = + reinterpret_cast(static_cast(view.payload) + + sizeof(*head)); + const Uint64 tail = view.payloadSize - sizeof(*head); + // MessageBytes == 0 is the corrupt shape, never "no message": the NUL + // travels (CONTRACT-P5C §1, rule A's twin), so a legal record carries at + // least one byte and that byte terminates the string. + if (head->MessageBytes == 0 || tail < head->MessageBytes || + message[head->MessageBytes - 1] != '\0') { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"kEventGlError\"} - " + "MessageBytes %u against a %llu-byte tail, or the terminating " + "NUL is missing", + static_cast(head->MessageBytes), + static_cast(tail)); + std::abort(); } + // The error queue is CLIENT state, written here on the GL thread. The + // observation point is the next drain after the post - P9 owns the + // ordering (CONTRACT-P5C §4.2). + if (MG_State::pGLContext != nullptr) { + MG_State::pGLContext->RecordError( + static_cast(head->Code), + MakeUnique( + String(message, static_cast(head->MessageBytes - 1)))); + } else { + MGLOG_E_ONCE("MG_Remote client: a kEventGlError (code %u) arrived with " + "no live context and is dropped", + static_cast(head->Code)); + } + ++delivered; break; } default: MGLOG_W("MG_Remote client: reverse-channel record kind %u is not consumed in " - "P5 (R-12 takes three and a half of the ten callbacks)", + "P5c (four of the ten callbacks are armed; the rest are P9's)", static_cast(view.kind)); break; } @@ -944,6 +1032,8 @@ namespace MobileGL::MG_Remote::Client { Transport::EventRingConsumer& ClientSession::Events() { return m_events; } + Wire::SegmentTable& ClientSession::Segments() { return m_segments; } + Transport::RingControl* ClientSession::Control() { return m_shm.CmdControl(); } Transport::SessionSegments& ClientSession::Shm() { return m_shm; } diff --git a/MobileGL/MG_Remote/Client/ClientSession.h b/MobileGL/MG_Remote/Client/ClientSession.h index d6539327..5f7d1afb 100644 --- a/MobileGL/MG_Remote/Client/ClientSession.h +++ b/MobileGL/MG_Remote/Client/ClientSession.h @@ -202,6 +202,11 @@ namespace MobileGL::MG_Remote::Client { // OnSurfaceChanged. Drained by the GL thread between verbs. Transport::EventRingConsumer& Events(); + // The CLIENT's own segment table (P5c ev, CONTRACT-P5C §4.3): the writeback + // consumer resolves a SEG_EVENT blobref through it. Never the process resolver - + // table 3 installs that one on the server role only. + Wire::SegmentTable& Segments(); + Transport::RingControl* Control(); Transport::SessionSegments& Shm(); Transport::ITransport* Control_Plane(); diff --git a/MobileGL/MG_Remote/Server/ServerSession.cpp b/MobileGL/MG_Remote/Server/ServerSession.cpp index 6fa3a55b..8401dbec 100644 --- a/MobileGL/MG_Remote/Server/ServerSession.cpp +++ b/MobileGL/MG_Remote/Server/ServerSession.cpp @@ -16,9 +16,11 @@ #include #include +#include #include #include +#include #include namespace MobileGL::MG_Remote::Server { @@ -155,6 +157,120 @@ namespace MobileGL::MG_Remote::Server { std::abort(); } + // ---- P5c ev: the reverse channel's PRODUCER callbacks (CONTRACT-P5C §4.1) -------- + // + // With an active transport the three reverse entries of gMGPipeCallbacks belong to + // the SERVER session, never to the client: the backend calls them with exactly the + // arguments it always passed (a writeback blobref whose Offset IS the backend's own + // mapped pointer, a range array, a surface info), and what the callback does with + // the call is Reserve + fill the head + copy the payload + PublishEvents. The host + // pointer NEVER reaches the wire - what crosses is the inline copy inside the + // SEG_EVENT record, which is rule B binding the reverse direction exactly as it + // binds MGHostSpan (R-2). + // + // OVERFLOW IS A DEFECT, NOT A DROP (§4.4): all four events are lossless in P5c, so + // a Reserve that returns nullptr is Fatal{EventRingOverflow}. CountDrop and the + // eventRingFull latch stay built and stay unused-by-policy; P9 owns the policy. + [[noreturn]] void FatalEventRingOverflow(const char* eventName, Uint64 payloadBytes) { + MGLOG_F("MGPipe: Fatal{EventRingOverflow} - the producer of %s could not reserve " + "%llu bytes on SEG_EVENT. P5c's events are lossless and a full ring under " + "lockstep is a producer burst no measured workload has, so this is a " + "defect, not a drop (CONTRACT-P5C §4.4; the drop policy is P9's)", + eventName, static_cast(payloadBytes)); + std::abort(); + } + + void ServerOnBufferWriteback(MG_Pipe::MGPipeHandle res, Uint64 offset, + MG_Pipe::MGPBlobRef bytes) { + if (bytes.Seg != MG_Pipe::kMGHostSpanSegNone) { + // The backend handed over a segment-tagged blobref. The ONLY legal shape at + // this boundary is the monolith one - Offset is the mapped address, valid + // for this call - because the segment copy is THIS function's own job. + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"OnBufferWriteback.Seg\"} - the " + "writeback producer was handed Seg %u; at the backend boundary the " + "blobref names the backend's own mapped bytes (Seg = " + "kMGHostSpanSegNone) and the copy into SEG_EVENT is the producer's", + bytes.Seg); + std::abort(); + } + ServerSession& session = ServerSessionInstance(); + const Uint64 payloadBytes = sizeof(Transport::EventBufferWritebackHead) + bytes.Size; + void* slot = session.Events().Reserve(Transport::kEventBufferWriteback, payloadBytes); + if (slot == nullptr) { + FatalEventRingOverflow("kEventBufferWriteback", payloadBytes); + } + Transport::EventBufferWritebackHead head{}; + head.Resource = Transport::EventHandle{res.Slot, res.Gen}; + head.Offset = offset; + head.Size = bytes.Size; + std::memcpy(slot, &head, sizeof(head)); + if (bytes.Size != 0) { + std::memcpy(static_cast(slot) + sizeof(head), + reinterpret_cast(static_cast(bytes.Offset)), + static_cast(bytes.Size)); + } + session.PublishEvents(); + } + + void ServerOnGpuWritten(MG_Pipe::MGPipeHandle res, Uint rangeCount, + const MG_Pipe::MGPRange* ranges) { + ServerSession& session = ServerSessionInstance(); + const Uint64 tailBytes = static_cast(rangeCount) * sizeof(Transport::EventRange); + const Uint64 payloadBytes = sizeof(Transport::EventGpuWrittenHead) + tailBytes; + void* slot = session.Events().Reserve(Transport::kEventGpuWritten, payloadBytes); + if (slot == nullptr) { + FatalEventRingOverflow("kEventGpuWritten", payloadBytes); + } + Transport::EventGpuWrittenHead head{}; + head.Resource = Transport::EventHandle{res.Slot, res.Gen}; + head.RangeCount = static_cast(rangeCount); + std::memcpy(slot, &head, sizeof(head)); + if (tailBytes != 0) { + // EventRange and MGPRange are the same two Uint64s (EventRing.h:61-66 asserts + // it), so the tail is a plain copy rather than a per-element conversion. + std::memcpy(static_cast(slot) + sizeof(head), ranges, + static_cast(tailBytes)); + } + session.PublishEvents(); + } + + void ServerOnSurfaceChanged(const MG_Pipe::MGPSurfaceInfo* info) { + ServerSession& session = ServerSessionInstance(); + constexpr Uint64 payloadBytes = sizeof(Transport::EventSurfaceChangedHead); + void* slot = session.Events().Reserve(Transport::kEventSurfaceChanged, payloadBytes); + if (slot == nullptr) { + FatalEventRingOverflow("kEventSurfaceChanged", payloadBytes); + } + Transport::EventSurfaceChangedHead head{}; + if (info != nullptr) { + head.Width = info->Width; + head.Height = info->Height; + head.InternalFormat = info->InternalFormat; + head.Samples = info->Samples; + head.Layers = info->Layers; + head.IsDefault = info->IsDefault; + } + std::memcpy(slot, &head, sizeof(head)); + session.PublishEvents(); + } + + // One installer for both roles' tables, so the check exists in exactly one spelling: + // writing over an entry somebody else claimed is Fatal{RoleViolation, + // "callback-double-install"} - MGPipeCallbacks' "never over an entry a backend + // already claimed" comment, made a check (CONTRACT-P5C §4.1). Finding OUR OWN + // function there is the idempotent repeat, not a second installation. + template + void InstallReverseCallback(Fn& entry, Fn producer) { + if (entry != nullptr && entry != producer) { + MGLOG_F("MGPipe: Fatal{RoleViolation, \"callback-double-install\"} - a reverse " + "MGPipeCallbacks entry is already claimed by a different function. With " + "an active transport the three reverse entries are the server " + "session's producers; the client installs them under monolith only"); + std::abort(); + } + entry = producer; + } + ServerSession* g_active = nullptr; } // namespace @@ -337,6 +453,19 @@ namespace MobileGL::MG_Remote::Server { m_accepted = true; g_active = this; + + // ---- P5c ev: the three reverse-channel producers are THIS session's (CONTRACT-P5C + // §4.1), installed before the apply thread can apply a record that produces one and + // uninstalled at Close after the join. Under monolith these entries are the + // client's (MGPipeInstallClientResourceCallbacks, monolith-only now); a session is + // by definition not monolith, so finding one of them claimed here is the double + // installation the check exists to name. + InstallReverseCallback(MG_Pipe::gMGPipeCallbacks.OnBufferWriteback, + &ServerOnBufferWriteback); + InstallReverseCallback(MG_Pipe::gMGPipeCallbacks.OnGpuWritten, &ServerOnGpuWritten); + InstallReverseCallback(MG_Pipe::gMGPipeCallbacks.OnSurfaceChanged, + &ServerOnSurfaceChanged); + LogMemory("accept"); if (!CallMaskIsSet()) { @@ -415,6 +544,17 @@ namespace MobileGL::MG_Remote::Server { // Uninstall AFTER the apply thread has joined, never before: a record still in // flight can still resolve a segment offset (table 3's fourth column). Wire::SegmentTable::UninstallProcessResolver(); + // The reverse-channel producers go with the session that owns them - release + // only the entries that are still OURS, never one a later owner installed. + if (MG_Pipe::gMGPipeCallbacks.OnBufferWriteback == &ServerOnBufferWriteback) { + MG_Pipe::gMGPipeCallbacks.OnBufferWriteback = nullptr; + } + if (MG_Pipe::gMGPipeCallbacks.OnGpuWritten == &ServerOnGpuWritten) { + MG_Pipe::gMGPipeCallbacks.OnGpuWritten = nullptr; + } + if (MG_Pipe::gMGPipeCallbacks.OnSurfaceChanged == &ServerOnSurfaceChanged) { + MG_Pipe::gMGPipeCallbacks.OnSurfaceChanged = nullptr; + } } m_consumer.Detach(); m_commands = Transport::RingConsumer(); @@ -491,6 +631,40 @@ namespace MobileGL::MG_Remote::Server { m_consumer.NotifyClient(); } + void ServerSession::PostGlError(Uint32 code, const char* message) { + if (!m_accepted) { + // The same answer PipeInputs::RecordError's own no-live-context arm gives: a + // dropped driver error is loud, never silent. + MGLOG_E_ONCE("MG_Remote server: PostGlError (code %u) before Accept - the error is " + "dropped, because there is no SEG_EVENT to carry it on", + static_cast(code)); + return; + } + // The message rides INLINE, NUL-terminated, MessageBytes = strlen + 1 (CONTRACT-P5C + // §1), truncated to the cap at the producer - which is here, and is why the cap is a + // constant of the wire header rather than a negotiated value. + SizeT length = message == nullptr ? 0 : std::strlen(message); + if (length >= Transport::kEventGlErrorMaxMessageBytes) { + length = Transport::kEventGlErrorMaxMessageBytes - 1; + } + const Uint32 messageBytes = static_cast(length) + 1; + const Uint64 payloadBytes = sizeof(Transport::EventGlErrorHead) + messageBytes; + void* slot = m_events.Reserve(Transport::kEventGlError, payloadBytes); + if (slot == nullptr) { + FatalEventRingOverflow("kEventGlError", payloadBytes); + } + Transport::EventGlErrorHead head{}; + head.Code = code; + head.MessageBytes = messageBytes; + std::memcpy(slot, &head, sizeof(head)); + auto* tail = reinterpret_cast(static_cast(slot) + sizeof(head)); + if (length != 0) { + std::memcpy(tail, message, length); + } + tail[length] = '\0'; + PublishEvents(); + } + // Both of these advance AND ring, through SessionConsumer. The free functions in namespace // Watermark do not ring: a client parked in WaitForPresentAck(kWaitForever) needs the pair. void ServerSession::AdvanceCompletedFrame(Uint64 serial) { diff --git a/MobileGL/MG_Remote/Server/ServerSession.h b/MobileGL/MG_Remote/Server/ServerSession.h index 9186eced..3c3b4b66 100644 --- a/MobileGL/MG_Remote/Server/ServerSession.h +++ b/MobileGL/MG_Remote/Server/ServerSession.h @@ -158,6 +158,18 @@ namespace MobileGL::MG_Remote::Server { // caller and therefore compiles for every wrong pairing; the session is the thing // that knows which bell belongs to the client. void PublishEvents(); + + // ---- P5c ev: the reverse channel's producers (CONTRACT-P5C §4) ------------------- + // + // With an active transport the three reverse MGPipeCallbacks entries are THIS + // session's producer callbacks (Accept installs them, Close uninstalls them), and + // the fourth event kind is posted through here: PipeInputs::RecordError's transport + // arm calls PostGlError with the frontend ErrorCode widened and the ErrorInfo's + // message. `message` may be null; a longer message than + // Transport::kEventGlErrorMaxMessageBytes is truncated, which is the contract's + // ruling (§1), not a check. A session that has not accepted drops the error with a + // loud line, exactly as RecordError's own no-live-context arm does. + void PostGlError(Uint32 code, const char* message); Transport::ITransport* Control_Plane(); // completedFrameSerial / presentAckSerial: the two watermarks only the server can diff --git a/MobileGL/MG_Remote/Transport/EventRing.h b/MobileGL/MG_Remote/Transport/EventRing.h index bca91fda..13be1ca3 100644 --- a/MobileGL/MG_Remote/Transport/EventRing.h +++ b/MobileGL/MG_Remote/Transport/EventRing.h @@ -42,12 +42,14 @@ namespace MobileGL::MG_Remote::Transport { // Record kinds on SEG_EVENT. 0 is kRingPadRecordKind and can never be an - // event, which is why the list starts at 1. + // event, which is why the list starts at 1. APPEND-ONLY, for the same reason + // the opcode space is (CONTRACT-P5C §7.7): kEventGlError is the first addition. enum EventKind : std::uint16_t { kEventNone = 0, kEventBufferWriteback = 1, // MGPipeCallbacks::OnBufferWriteback kEventGpuWritten = 2, // MGPipeCallbacks::OnGpuWritten kEventSurfaceChanged = 3, // MGPipeCallbacks::OnSurfaceChanged + kEventGlError = 4, // PipeInputs::RecordError, posted by the server session }; // The 8-byte {slot, gen} pair, mirrored (MGPipeHandles.h:54-65). @@ -97,6 +99,21 @@ namespace MobileGL::MG_Remote::Transport { }; static_assert(sizeof(EventSurfaceChangedHead) == 24, "MGPSurfaceInfo is 24 bytes on the wire"); + // PipeInputs::RecordError(code, info) (CONTRACT-P5C §1). The NUL-terminated + // message follows this head INSIDE THE RECORD: MessageBytes = strlen + 1 and + // the NUL travels, so MessageBytes == 0 is the corrupt shape, never "no + // message". A longer message is TRUNCATED AT THE PRODUCER to the cap; the cap + // is a static_assert here, not a runtime check, because it is part of the + // wire's shape rather than a policy a peer may disagree about. + struct EventGlErrorHead { + std::uint32_t Code; // the frontend ErrorCode, widened + std::uint32_t MessageBytes; // strlen + 1, the NUL included + }; + static_assert(sizeof(EventGlErrorHead) == 8, "wire shape"); + inline constexpr std::uint32_t kEventGlErrorMaxMessageBytes = 1024; + static_assert(kEventGlErrorMaxMessageBytes == 1024, + "CONTRACT-P5C §1 caps an inline GL-error message at 1024 bytes, NUL included"); + // The server's end. One producer: the apply thread, by construction. class EventRingProducer { public: diff --git a/MobileGL/MG_Test/Wire/SessionTest.cpp b/MobileGL/MG_Test/Wire/SessionTest.cpp index 2bf12783..de25334a 100644 --- a/MobileGL/MG_Test/Wire/SessionTest.cpp +++ b/MobileGL/MG_Test/Wire/SessionTest.cpp @@ -822,6 +822,55 @@ TEST(SessionTest, TheEventRingCarriesTheThreeReverseCallbacks) { EXPECT_EQ(session.eventIn.DroppedEvents(), 0u); } +// P5c ev (CONTRACT-P5C §1, §4.5): the fourth event kind's unit round-trip, in the same shape +// as the three above - the producer writes the head plus the inline NUL-terminated message, +// the consumer reads every field back, the in-segment offset of the message stays inside +// SEG_EVENT, and a drained ring reports zero drops. +TEST(SessionTest, TheEventRingCarriesAGlError) { + SessionFixture session; + ASSERT_TRUE(session.Build(TestSizes())); + + const char message[] = "DirectVulkan: vkCreateGraphicsPipelines failed"; + const std::uint32_t messageBytes = static_cast(sizeof(message)); // NUL included + ASSERT_LT(messageBytes, kEventGlErrorMaxMessageBytes); + { + void* slot = session.eventOut.Reserve(kEventGlError, + sizeof(EventGlErrorHead) + messageBytes); + ASSERT_NE(slot, nullptr); + EventGlErrorHead head{}; + head.Code = 4; // ErrorCode::InvalidOperation, widened + head.MessageBytes = messageBytes; + std::memcpy(slot, &head, sizeof(head)); + std::memcpy(static_cast(slot) + sizeof(head), message, messageBytes); + } + session.eventOut.PublishAndNotify(session.clientTransport->SelfDoorbell(), + session.Control().producerParked); + + RingRecordView view{}; + ASSERT_TRUE(session.eventIn.Pop(view)); + EXPECT_EQ(view.kind, kEventGlError); + // The record's payload is rounded up to the ring's 8-byte alignment; the head's + // MessageBytes is the authoritative inline length. + ASSERT_GE(view.payloadSize, sizeof(EventGlErrorHead) + messageBytes); + EventGlErrorHead head{}; + std::memcpy(&head, view.payload, sizeof(head)); + EXPECT_EQ(head.Code, 4u); + EXPECT_EQ(head.MessageBytes, messageBytes); + const char* inlineMessage = + reinterpret_cast(static_cast(view.payload) + sizeof(head)); + EXPECT_STREQ(inlineMessage, message); + // The offset a consumer would resolve the inline message at: inside SEG_EVENT and past + // its control page, never a host address. + const std::uint64_t offset = session.eventIn.OffsetInSegment(inlineMessage); + EXPECT_GE(offset, sizeof(RingControl)); + EXPECT_LT(offset, session.clientSegments.AnnouncedSize(SessionSegmentSlot::Event)); + + EXPECT_FALSE(session.eventIn.Pop(view)); + session.eventIn.Drained(); + EXPECT_FALSE(session.eventIn.RingIsFull()); + EXPECT_EQ(session.eventIn.DroppedEvents(), 0u); +} + TEST(SessionTest, AFullEventRingLatchesTheFlagRatherThanDecidingWhatToDoAboutIt) { SessionFixture session; ASSERT_TRUE(session.Build(TestSizes()));