// MobileGL - MobileGL/MG_Remote/Client/ClientSession.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 // P5: construction, the handshake and lifetime are package s1's; the verb barrier and the // reply read that sits inside it are package c1's (EmitAndWait below is still c0's stub). #include "ClientSession.h" #include #include "../CapsCodec.h" #include "../Protocol/generated/protocol_generated.h" #include "../Server/ServerLoop.h" #include "../Server/ServerSession.h" #include "../Protocol/SurfaceOpCodec.h" #include "../Transport/FdPassing.h" #include "../Transport/InProcessTransport.h" #include "WireTables.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include // CONTRACT-P6 4.3: the handshake now carries real process ids. #if defined(_WIN32) #include #else #include #endif namespace MobileGL::MG_Remote::Client { namespace { // CONTRACT-P6 4.3. Hello::pid was hard-coded 0 and Welcome::serverPid ECHOED it, so both // were always 0 and neither named anything. Under spawn the pair is the cheapest arm // proof there is: two different values mean two processes, and a same-process session // cannot produce them. std::uint32_t SelfProcessId() { #if defined(_WIN32) return static_cast(::_getpid()); #else return static_cast(::getpid()); #endif } } // namespace #define MGP5_C0_STUB(what) \ SessionFail(MGFatalFamily::UnimplementedClientSession, \ "MGPipe: Fatal{UnimplementedClientSession, \"%s\"} - P5 packages s1/c1 have not " \ "landed this yet; c0 shipped the signature only", \ what) namespace { ClientSession* g_active = nullptr; // The same bounded handshake deadline the server uses. Bounded, not kWaitForever: a // bring-up that never answers has to be a red lane rather than a wedged CI job. constexpr Uint32 kHandshakeTimeoutMs = 5000; // cp (CONTRACT-P6 D5b): bounded, and its expiry is NOT fatal. A peer that // is gone is a different fact from a peer that is slow, and only the // doorbell death latch may say which. constexpr Uint32 kRemoteControlTimeoutMs = 5000; // The server has to create a backend before it binds, and a cold // software rasteriser is not fast. Bounded, and a NAMED refusal at the end. constexpr Uint32 kSpawnConnectTimeoutMs = 20000; // Teardown's drain. Also bounded, and for the same reason - table 3's order is // "publish and wait for the server to drain and acknowledge", and a wait with no // deadline there turns a lost record into a hung process exit. constexpr Uint32 kDrainTimeoutMs = 5000; MobileGLResult ReceiveEnvelope(Transport::ITransport& transport, std::vector& out, Uint32 timeoutMs) { Uint64 size = 0; MobileGLMutableByteSpan empty{nullptr, 0}; const MobileGLResult probe = transport.ReceiveFrame(empty, &size, timeoutMs); if (probe != MOBILEGL_ERR_BUFFER_TOO_SMALL) { return probe == MOBILEGL_OK ? MOBILEGL_ERR_PROTOCOL_MISMATCH : probe; } out.resize(static_cast(size)); MobileGLMutableByteSpan span{out.data(), out.size()}; return transport.ReceiveFrame(span, &size, 0); } const ::MobileGL::Wire::CtrlEnvelope* ParseEnvelope(const std::vector& bytes) { ::flatbuffers::Verifier verifier(bytes.data(), bytes.size()); if (!::MobileGL::Wire::VerifyCtrlEnvelopeBuffer(verifier)) { return nullptr; } if (!::MobileGL::Wire::CtrlEnvelopeBufferHasIdentifier(bytes.data())) { return nullptr; } return ::MobileGL::Wire::GetCtrlEnvelope(bytes.data()); } // CONTRACT-P6 4.3: THE DECLARED PROTOCOL VERSION, WHICH THIS SIDE NEVER READ. // // Hello has carried abiMajor/abiMinor since P5 and the SERVER checks them, but the // client only ever compared the fingerprint - so the asymmetry was silent in the one // direction that matters for spawn, where the peer is a SEPARATE IMAGE the operator // chose with MOBILEGL_IPC_SERVER_PATH. // // The fingerprint mixes AbiVersion and would catch a mismatch anyway, but it catches it // as "struct shapes" - which sends the reader looking for a layout change that is not // there. Checking the version FIRST turns the same failure into the sentence that // explains it. Both handshake sites call this; there are two, and only one of them used // to be reachable, which is how the gap survived review. [[noreturn]] void FatalProtocolVersionMismatch(unsigned theirMajor, unsigned theirMinor, const char* theirStamp) { SessionFail(MGFatalFamily::AbiMismatch, "MGPipe: Fatal{AbiMismatch, \"protocol version\"} ours=%u.%u theirs=%u.%u " "ourBuild=%s theirBuild=%s - the peer speaks a different MGPipe protocol. " "Under spawn this is most often a stale MOBILEGL_IPC_SERVER_PATH pointing at " "a libMobileGLServer.so built from another tree.", static_cast(MOBILEGL_PROTOCOL_ABI_MAJOR), static_cast(MOBILEGL_PROTOCOL_ABI_MINOR), theirMajor, theirMinor, GIT_COMMIT_HASH_SHORT, theirStamp == nullptr ? "?" : theirStamp); } [[noreturn]] void FatalAbiMismatch(const char* what, Uint64 ours, Uint64 theirs, const char* theirStamp) { SessionFail(MGFatalFamily::AbiMismatch, "MGPipe: Fatal{AbiMismatch, \"%s\"} ours=%llu theirs=%llu ourBuild=%s " "theirBuild=%s - never a downgrade: the caps block's size is ABI-dependent " "and every field past the first difference would be read at the wrong offset", what, static_cast(ours), static_cast(theirs), GIT_COMMIT_HASH_SHORT, theirStamp == nullptr ? "?" : theirStamp); } // Guarded the same way ServerSession's helpers are: MG_Config::Ipc only exists // behind MOBILEGL_BUILD_DISAGGREGATED (Config.h), and this file is only compiled // there today - but the guard is what keeps that true if the source list ever // changes, and an unguarded read would be a compile error nobody could read. Uint32 SpinUsFromConfig() { #if MOBILEGL_BUILD_DISAGGREGATED return MG_Config::Ipc.SpinUs; #else return Transport::kDefaultSpinUs; #endif } Bool VerbBarrierFromConfig() { #if MOBILEGL_BUILD_DISAGGREGATED return MG_Config::Ipc.VerbBarrier != 0; #else return true; #endif } // ---- c1: the barrier's two flags ------------------------------------------------ // // thread_local for the client's own "am I waiting", a shared atomic for "is the apply // thread inside the applier" - see ClientSession::InBarrierWait's note. thread_local Bool g_inBarrierWait = false; std::atomic g_applyThreadInsideApplier{false}; // A split client cannot observe a server's process-local diagnostic. The // role-local value remains useful to the applier itself; ordering between // roles is carried by the ring watermarks, not by this instrumentation. thread_local Bool g_roleInsideApplier = false; struct BarrierWaitScope { BarrierWaitScope() { g_inBarrierWait = true; } ~BarrierWaitScope() { g_inBarrierWait = false; } BarrierWaitScope(const BarrierWaitScope&) = delete; BarrierWaitScope& operator=(const BarrierWaitScope&) = delete; }; // A DEADLOCK DETECTOR, NOT A PERFORMANCE GATE. The barrier exists so that a record the // applier never retires is reported instead of hanging: a lost record never completes at // all, while a slow apply completes late. The bound only has to sit far above the slowest // legitimate apply. // // lavapipe/llvmpipe have legitimate apply-thread stalls that reach the tens of seconds: // texture-handle registration (vkCreateImageView -> lvp_CreateImageView -> // llvmpipe_register_texture) blocks the apply thread on a JIT/futex path, measured at // 29.3-39.7 s in a single stall, ~44.6 s cumulatively over one inproc retrace run. A 30 s // bound therefore red-lanes those runs intermittently while the same stall on a monolith // run is simply slow - the monolith has no equivalent cap on this path. 120 s is >3x the // largest observed stall and keeps the watchdog's job: a record that is truly lost still // terminates the run, and the Fatal names the seq. constexpr Uint32 kBarrierTimeoutMs = 120000; Uint64 AppliedWaitBudgetMs(MG_Pipe::MGPWireOp op, const void* payload) { if (op != MG_Pipe::MGPWireOp::FenceWait) return kBarrierTimeoutMs; const Uint64 timeoutNs = static_cast(payload)->TimeoutNs; // ClientWaitSync may legitimately block longer than the ordinary verb barrier. // Round up without overflowing UINT64_MAX, then allow the usual transport grace. // FenceWaitServer queues a GPU wait; its GL_TIMEOUT_IGNORED is not a CPU budget. return timeoutNs / 1000000 + (timeoutNs % 1000000 != 0) + kBarrierTimeoutMs; } // Defined below; declared here because the applied wait has to be able to drain // SEG_EVENT while it is parked (P5e §2.6). Uint32 DrainEventRing(Transport::EventRingConsumer& events); Transport::SessionWait WaitForAppliedBudget(Transport::SessionProducer& producer, Transport::EventRingConsumer& events, Uint64 seq, Uint64 remainingMs) { // The transport takes Uint32 milliseconds with UINT32_MAX meaning forever. // Keep even the largest GL timeout finite, using bounded chunks and preserving // the doorbell's immediate shutdown result. Avoid a giant chrono deadline too. constexpr Uint32 kMaxFiniteWaitMs = Transport::kWaitForever - 1; for (;;) { const Uint32 chunkMs = remainingMs > kMaxFiniteWaitMs ? kMaxFiniteWaitMs : static_cast(remainingMs); // P5e (ra, CONTRACT-P5E §2.6): THE PARK BREAKS ON TWO THINGS. A server that // ran out of SEG_EVENT stops producing and therefore stops applying, so a // waiter that could only be woken by appliedSeq would wait for a watermark // nothing is going to move - and it is holding the only drain there is. The // second exit is that case: drain, clear the latch, ring the server, park // again. `remainingMs` is deliberately NOT charged for it; a wake that did // the queue's work is not the caller's budget being spent. const auto wait = producer.WaitForAppliedOrEventBacklog(seq, chunkMs); if (wait == Transport::SessionWait::Reached) { Transport::RingControl* control = producer.Control(); if (control == nullptr || Transport::Watermark::Reached(control->Progress.appliedSeq, seq)) { return wait; } DrainEventRing(events); continue; } if (wait != Transport::SessionWait::TimedOut || remainingMs <= chunkMs) return wait; remainingMs -= chunkMs; } } // How many queued control frames one pump will drain. A backlog deeper than this is a // finding, not a steady state. constexpr Uint32 kMaxControlFramesPerPump = 16; const char* TransportModeName(MG_Config::TransportMode mode) { switch (mode) { case MG_Config::TransportMode::Monolith: return "monolith"; case MG_Config::TransportMode::InProcess: return "inproc"; case MG_Config::TransportMode::Spawn: return "spawn"; case MG_Config::TransportMode::UnixSocket: return "unix:"; case MG_Config::TransportMode::NamedPipe: return "pipe:"; } return "?"; } // ---- c1: the reverse channel's reading end --------------------------------------- // // R-12's three: OnBufferWriteback (#3), OnGpuWritten (#2), OnSurfaceChanged (#7). // Drained BY THE GL THREAD BETWEEN VERBS, which under the barrier means immediately // after appliedSeq reaches this record - the one moment at which the apply thread is // known not to be inside the applier. // // 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; Transport::RingRecordView view{}; Bool corrupt = false; while (events.Pop(view, &corrupt)) { if (corrupt) { SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"event-ring\"} - the reverse " "channel's record stream is corrupt"); } switch (view.kind) { case Transport::kEventBufferWriteback: { if (view.payloadSize < sizeof(Transport::EventBufferWritebackHead)) break; const auto* head = static_cast(view.payload); const void* bytes = static_cast(view.payload) + sizeof(*head); if (view.payloadSize - sizeof(*head) < head->Size) { SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"buffer-writeback\"} - the " "head declares %llu inline bytes and the record carries %llu", static_cast(head->Size), static_cast(view.payloadSize - sizeof(*head))); } // 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: { if (view.payloadSize < sizeof(Transport::EventGpuWrittenHead)) break; const auto* head = static_cast(view.payload); const Uint64 tail = view.payloadSize - sizeof(*head); if (tail / sizeof(Transport::EventRange) < head->RangeCount) { SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"gpu-written\"} - RangeCount " "%u does not fit the record's %llu tail bytes", static_cast(head->RangeCount), static_cast(tail)); } // 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; 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') { SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"kEventGlError\"} - " "MessageBytes %u against a %llu-byte tail, or the terminating " "NUL is missing", static_cast(head->MessageBytes), static_cast(tail)); } // 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 " "P5c (four of the ten callbacks are armed; the rest are P9's)", static_cast(view.kind)); break; } } // AND ONLY NOW. Every payload pointer above has been consumed. events.Drained(); return delivered; } // ---- c1: the CapsSnapshot -> CapsMirror adoption, in ONE place ------------------- // // Every field of the snapshot has exactly one reader, and a field that fails to decode // is a REFUSAL rather than a partial adopt: CompileEnv.cpp:123 copies the whole // DynamicBackendParameters struct into the compile env, so a mirror that adopted three // of four members would put the fourth's default into a shader fingerprint. Bool AdoptCapsSnapshot(const ::MobileGL::Wire::CapsSnapshot* snapshot) { if (snapshot == nullptr) return false; MG_Pipe::MGPCaps caps{}; const auto* dynamicBytes = snapshot->dynamicParameters(); if (dynamicBytes == nullptr || dynamicBytes->size() != sizeof(caps.Dynamic)) { // The Hello/Welcome fingerprint already asserted both peers agree on // sizeof(DynamicBackendParameters), so a disagreement HERE is a corrupt frame // rather than an ABI skew - which is why it is a refusal and not FatalAbiMismatch. MGLOG_E("MG_Remote client: CapsSnapshot carries %llu dynamic bytes, this build's " "struct is %llu - the snapshot is refused whole", static_cast(dynamicBytes == nullptr ? 0 : dynamicBytes->size()), static_cast(sizeof(caps.Dynamic))); return false; } std::memcpy(&caps.Dynamic, dynamicBytes->data(), sizeof(caps.Dynamic)); caps.CallMask = snapshot->callMask(); RendererInfo renderer{}; const auto* rendererBytes = snapshot->rendererInfo(); if (rendererBytes == nullptr || !DecodeRendererInfo(rendererBytes->data(), rendererBytes->size(), renderer)) { MGLOG_E("MG_Remote client: CapsSnapshot's rendererInfo blob did not decode"); return false; } MG_Backend::FormatCapabilityCache formats{}; const auto* formatBytes = snapshot->formatCaps(); if (formatBytes == nullptr || !DecodeFormatCapabilities(formatBytes->data(), formatBytes->size(), formats)) { MGLOG_E("MG_Remote client: CapsSnapshot's formatCaps blob did not decode"); return false; } const String apiVersion = snapshot->apiVersion() == nullptr ? String{} : String{snapshot->apiVersion()->c_str()}; // THE SERVER'S BACKEND TYPE, NEVER A NEW "Remote" ENUMERATOR and never guessed from // the renderer string: GL_Framebuffer.cpp:47, GL_Texture.cpp:6536 and // CompileEnv.cpp:122 SWITCH on it, and a value they do not know takes a wrong arm // rather than failing. const Uint32 rawBackend = snapshot->backendType(); if (rawBackend >= static_cast(BackendType::BackendTypeCount)) { MGLOG_E("MG_Remote client: CapsSnapshot names backend type %u, which this build " "has no enumerator for - the snapshot is refused rather than folded onto " "Unknown, which three frontend switches would silently mis-branch on", static_cast(rawBackend)); return false; } CapsMirrorInstance().Adopt(caps, formats, renderer, apiVersion, static_cast(rawBackend)); return true; } } // namespace // Null, not a Fatal: MG_Backend::Init() asks whether a session exists before it decides to // install the remote backend object, and that question has a legitimate "no" - it is the // monolith answer. Every call that PRESUMES a session aborts instead. ClientSession* ClientSession::Active() { return g_active; } Bool ClientSession::DeviceLost() { const ClientSession* session = Active(); return session != nullptr && session->m_deviceLost.load(std::memory_order_acquire); } void ClientSession::LatchDeviceLost(const char* why) { // exchange, so the log line is written EXACTLY once however many waits notice the same // hangup at the same moment - and there will be several: the barrier, the control wait // and the event pump can all be in flight when the server goes. if (m_deviceLost.exchange(true, std::memory_order_acq_rel)) { return; } MGLOG_E("MGPipe: DEVICE LOST - %s. The server process is gone; every verb from here is " "DECLINED and glGetGraphicsResetStatus reports GL_UNKNOWN_CONTEXT_RESET. This is " "latched from the peer's HANGUP and not from any deadline (CONTRACT-P6 5.4): a " "server one frame behind is P5e's intended steady state and must never read as a " "dead one. There is no recovery - MOBILEGL_IPC_RESPAWN is the stage that would " "add one and it is refused by name until then.", why == nullptr ? "the peer hung up" : why); } ClientSession& ClientSessionInstance() { // Leak at exit, deliberately and per ID-8, exactly as ServerSessionInstance does. static ClientSession* instance = new ClientSession{}; return *instance; } ClientSession::~ClientSession() { if (m_started) { Stop(); } } Bool ClientSession::Started() const { return m_started; } MobileGLResult ClientSession::Start(MG_Config::TransportMode mode, const String& endpoint) { if (m_started) { return MOBILEGL_ERR_INVALID_ARGUMENT; } // A NAMED ERROR, NEVER A FALLBACK TO MONOLITH. A silent fallback here is exactly the // "the split lane ran monolith and went green" failure the whole phase is built to // make impossible (ARCHITECTURE.md 10.3), so every mode this build cannot serve is // refused by name rather than degraded. if (mode != MG_Config::TransportMode::InProcess) { MGLOG_E("MG_Remote client: MOBILEGL_TRANSPORT=%s%s is refused by name - P5 implements " "`inproc` only, and falling back to monolith would make this lane green for " "the wrong reason. spawn / unix: / pipe: are P6's", TransportModeName(mode), endpoint.empty() ? "" : endpoint.c_str()); return MOBILEGL_ERR_UNSUPPORTED; } // ---- 1. the control plane and the two bells. The transport owns the bells; THE // SESSION owns the rings, and the accessors stay off ITransport (contract §3.9). std::unique_ptr clientEnd; std::unique_ptr serverEnd; Transport::InProcessTransport::CreatePair(clientEnd, serverEnd); return StartOverTransportPair(std::move(clientEnd), std::move(serverEnd)); } MobileGLResult ClientSession::StartOverTransportPair( std::unique_ptr clientEnd, std::unique_ptr serverEnd) { if (m_started) { return MOBILEGL_ERR_INVALID_ARGUMENT; } if (clientEnd == nullptr || serverEnd == nullptr) { MGLOG_E("MG_Remote client: StartOverTransportPair needs both ends of one " "InProcessTransport::CreatePair"); return MOBILEGL_ERR_INVALID_ARGUMENT; } m_clientTransport = std::move(clientEnd); m_serverTransport = std::move(serverEnd); m_transport = m_clientTransport.get(); // ---- 2. Hello. Sent before the server accepts: InProcessTransport queues whole // messages, so one thread can drive both halves of the handshake in order. const Uint64 fingerprint = CapsAbiFingerprint(); { ::flatbuffers::FlatBufferBuilder builder(512); auto stamp = builder.CreateString(GIT_COMMIT_HASH_SHORT); auto hello = ::MobileGL::Wire::CreateHello( builder, MOBILEGL_PROTOCOL_ABI_MAJOR, MOBILEGL_PROTOCOL_ABI_MINOR, stamp, /*backendType=*/0u, /*pid=*/SelfProcessId(), /*configBlob=*/0, fingerprint); auto root = ::MobileGL::Wire::CreateCtrlEnvelope( builder, ::MobileGL::Wire::CtrlMsg::Hello, hello.Union()); ::MobileGL::Wire::FinishCtrlEnvelopeBuffer(builder, root); const MobileGLResult sent = m_transport->SendFrame( MobileGLByteSpan{builder.GetBufferPointer(), builder.GetSize()}); if (sent != MOBILEGL_OK) { Stop(); return sent; } } // ---- 3. the server half: ABI assert, four segments, Welcome. Server::ServerSession& server = Server::ServerSessionInstance(); const MobileGLResult accepted = server.Accept(*m_serverTransport); if (accepted != MOBILEGL_OK) { Stop(); return accepted; } // ---- 4. Welcome, and this side's half of the ABI assertion. { std::vector frame; const MobileGLResult received = ReceiveEnvelope(*m_transport, frame, kHandshakeTimeoutMs); if (received != MOBILEGL_OK) { MGLOG_E("MG_Remote client: no Welcome within %u ms (rc=%d)", kHandshakeTimeoutMs, static_cast(received)); Stop(); return received; } const ::MobileGL::Wire::CtrlEnvelope* envelope = ParseEnvelope(frame); // msg_as_Welcome() IS PART OF THE GUARD - flatbuffers' Verifier::VerifyTable is // `return !table || table->Verify(*this)`, so a NULL union member verifies while // msg_type() still reports Welcome. See ServerSession::Accept for the same guard. if (envelope == nullptr || envelope->msg_type() != ::MobileGL::Wire::CtrlMsg::Welcome || envelope->msg_as_Welcome() == nullptr) { MGLOG_E("MG_Remote client: the server's first control frame is not a verifiable " "Welcome"); Stop(); return MOBILEGL_ERR_PROTOCOL_MISMATCH; } const ::MobileGL::Wire::Welcome* welcome = envelope->msg_as_Welcome(); const char* theirStamp = welcome->buildFingerprint() == nullptr ? nullptr : welcome->buildFingerprint()->c_str(); // 4.3: the pid the SERVER stated, taken before any check can abort, so a Fatal line // can name it too. m_peerServerPid = static_cast(welcome->serverPid()); // The VERSION before the fingerprint: both would fire, and only one names the cause. if (welcome->abiMajor() != MOBILEGL_PROTOCOL_ABI_MAJOR || welcome->abiMinor() != MOBILEGL_PROTOCOL_ABI_MINOR) { FatalProtocolVersionMismatch(static_cast(welcome->abiMajor()), static_cast(welcome->abiMinor()), theirStamp); } if (welcome->abiFingerprint() != fingerprint) { FatalAbiMismatch("struct shapes", fingerprint, welcome->abiFingerprint(), theirStamp); } // The four SegmentRefs are what a spawn client MAPS (P6). Under inproc the mapping // already exists, so what they are good for here is the cross-check that the two // sides agree about the geometry at all - which is the assertion that would // otherwise first run in P6, on the day it is expensive to be wrong. using Slot = Transport::SessionSegmentSlot; const auto agrees = [&](const ::MobileGL::Wire::SegmentRef* ref, Slot slot, const char* name) { if (ref == nullptr || ref->sizeBytes() != server.Shm().AnnouncedSize(slot)) { MGLOG_E("MG_Remote client: Welcome's %s SegmentRef announces %llu bytes, the " "server mapped %llu", name, static_cast(ref == nullptr ? 0 : ref->sizeBytes()), static_cast(server.Shm().AnnouncedSize(slot))); return false; } return true; }; if (!agrees(welcome->cmdRing(), Slot::Cmd, "cmd") || !agrees(welcome->stageRing(), Slot::Stage, "stage") || !agrees(welcome->replyPool(), Slot::Reply, "reply") || !agrees(welcome->eventRing(), Slot::Event, "event")) { Stop(); return MOBILEGL_ERR_PROTOCOL_MISMATCH; } } // ---- 5. attach to the four segments. Under `inproc` this is the SAME mapping booked // under the client role; under `spawn` it becomes ShmSegment::Adopt of the fds the // server passed by SCM_RIGHTS, which is why nothing below this line knows which it was. const MobileGLResult attached = m_shm.AttachInProcess(server.Shm(), Transport::MemoryRole::Client); if (attached != MOBILEGL_OK) { Stop(); return attached; } return FinishStartup(&m_clientTransport->PeerDoorbell(), &m_clientTransport->SelfDoorbell(), /*startApplyThreadHere=*/true); } MobileGLResult ClientSession::StartSpawned() { if (m_started) { return MOBILEGL_ERR_INVALID_ARGUMENT; } // A rendezvous nobody else can collide with. Independent processes need // a NAME; making it per-process-per-instant is what stops two runs on // one machine - which `ctest -j` produces by construction - finding each // other's server. // AN ABSTRACT NAME ('@'), not a path under /tmp. An Android app has no // writable /tmp and the first device run proved it the blunt way: the // server launched, bind() had nowhere to put the node, and the client // refused by name 20 s later. The abstract namespace needs no directory, // no mode bits and no cleanup after a crash, and its reach - the network // namespace - is exactly one app's processes. const std::string endpoint = std::string("@mgl-") + std::to_string(static_cast(::getpid())) + "-" + std::to_string(reinterpret_cast(this)); const MobileGLResult launched = Server::LaunchServer(std::string(MG_Config::Ipc.ServerPath.c_str()), endpoint, &m_spawned); if (launched != MOBILEGL_OK) { return launched; } std::unique_ptr socket; // The bounded connect retry is what tolerates the server still binding - // and it is the CLIENT's, not the launcher's. An exhausted budget is a // NAMED refusal; there is no monolith fallback from here. const MobileGLResult connected = Transport::SocketTransport::ConnectTo(endpoint, kSpawnConnectTimeoutMs, socket); if (connected != MOBILEGL_OK) { int exitCode = 0; Server::ReapServer(m_spawned, 2000, &exitCode); MGLOG_E("MG_Remote client: could not reach the server we launched at \"%s\" " "(rc=%d, server exit=%d)", endpoint.c_str(), static_cast(connected), exitCode); return connected; } // THE ONE FACT A MONOLITH OR inproc RUN CANNOT PRODUCE: a pid that is // not ours. The ConfigLoader marker proves the VALUE resolved; this // proves the SERVER ROLE LEFT THIS PROCESS, which is the whole claim the // SPAWN arm of the retrace matrix makes. run_trace_case.cmake requires // BOTH, so a spawn arm cannot go green on a transport that resolved and // then quietly ran the server role locally. const MobileGLResult started = StartOverSocket(std::move(socket)); if (started == MOBILEGL_OK) { MGLOG_I("MG_Remote client: spawn ARMED - the server role runs in pid %d, reached at " "\"%s\"", m_spawned.pid, endpoint.c_str()); } return started; } MobileGLResult ClientSession::StartOverSocket( std::unique_ptr transport) { if (m_started) { return MOBILEGL_ERR_INVALID_ARGUMENT; } if (transport == nullptr) { MGLOG_E("MG_Remote client: StartOverSocket needs a connected transport"); return MOBILEGL_ERR_INVALID_ARGUMENT; } m_socketTransport = Move(transport); m_transport = m_socketTransport.get(); // ---- Hello. The same bytes as inproc; the fingerprint is still compared // for equality, because under Dial == Fork the child IS the same binary and // comparing it catches a stale MOBILEGL_IPC_SERVER_PATH (CONTRACT-P6 4.1). const Uint64 fingerprint = CapsAbiFingerprint(); { ::flatbuffers::FlatBufferBuilder builder(512); auto stamp = builder.CreateString(GIT_COMMIT_HASH_SHORT); auto hello = ::MobileGL::Wire::CreateHello( builder, MOBILEGL_PROTOCOL_ABI_MAJOR, MOBILEGL_PROTOCOL_ABI_MINOR, stamp, /*backendType=*/0u, /*pid=*/SelfProcessId(), /*configBlob=*/0, fingerprint); auto root = ::MobileGL::Wire::CreateCtrlEnvelope( builder, ::MobileGL::Wire::CtrlMsg::Hello, hello.Union()); ::MobileGL::Wire::FinishCtrlEnvelopeBuffer(builder, root); const MobileGLResult sent = m_transport->SendFrame( MobileGLByteSpan{builder.GetBufferPointer(), builder.GetSize()}); if (sent != MOBILEGL_OK) { Stop(); return sent; } } // ---- Welcome, and the four announced sizes. THERE IS NO LOCAL SERVER TO // ASK: the inproc path cross-checks Welcome against ServerSessionInstance()'s // own mapping, which a6 showed is vacuous against a real peer. Here the wire // values are the only values, which is what that check has to become on // every lane (CONTRACT-P6 4.4). Uint64 announced[4] = {0, 0, 0, 0}; const Uint32 replySlotCount = Transport::kDefaultReplySlotCount; { std::vector frame; const MobileGLResult received = ReceiveEnvelope(*m_transport, frame, kHandshakeTimeoutMs); if (received != MOBILEGL_OK) { MGLOG_E("MG_Remote client: no Welcome from the spawned server within %u ms (rc=%d)", kHandshakeTimeoutMs, static_cast(received)); Stop(); return received; } const ::MobileGL::Wire::CtrlEnvelope* envelope = ParseEnvelope(frame); if (envelope == nullptr || envelope->msg_type() != ::MobileGL::Wire::CtrlMsg::Welcome || envelope->msg_as_Welcome() == nullptr) { MGLOG_E("MG_Remote client: the spawned server first frame is not a verifiable " "Welcome"); Stop(); return MOBILEGL_ERR_PROTOCOL_MISMATCH; } const ::MobileGL::Wire::Welcome* welcome = envelope->msg_as_Welcome(); const char* theirStamp = welcome->buildFingerprint() == nullptr ? nullptr : welcome->buildFingerprint()->c_str(); // 4.3: the pid the SERVER stated, taken before any check can abort, so a Fatal line // can name it too. m_peerServerPid = static_cast(welcome->serverPid()); // The VERSION before the fingerprint: both would fire, and only one names the cause. if (welcome->abiMajor() != MOBILEGL_PROTOCOL_ABI_MAJOR || welcome->abiMinor() != MOBILEGL_PROTOCOL_ABI_MINOR) { FatalProtocolVersionMismatch(static_cast(welcome->abiMajor()), static_cast(welcome->abiMinor()), theirStamp); } if (welcome->abiFingerprint() != fingerprint) { FatalAbiMismatch("struct shapes", fingerprint, welcome->abiFingerprint(), theirStamp); } const ::MobileGL::Wire::SegmentRef* refs[4] = {welcome->cmdRing(), welcome->stageRing(), welcome->replyPool(), welcome->eventRing()}; for (int index = 0; index < 4; ++index) { if (refs[index] == nullptr || refs[index]->sizeBytes() == 0) { MGLOG_E("MG_Remote client: Welcome SegmentRef %d is missing or zero-sized", index); Stop(); return MOBILEGL_ERR_PROTOCOL_MISMATCH; } announced[index] = refs[index]->sizeBytes(); } } // ---- the four descriptors. THIS is the step inproc does not have, and the // reason SCM_RIGHTS is in this design at all: the segments are anonymous - // ASharedMemory_create has no filesystem name and memfd / shm_open+unlink // are not openable either - so the descriptor IS the only key. // // The slot travels in the sideband rather than being inferred from arrival // order, so a reordered or dropped offer is a NAMED mismatch instead of two // segments quietly swapped. // Seven offers: four segments, then THREE bell descriptors (slots 4..6). // Three because each side parks on one and rings another; see ServerMain. int fds[7] = {-1, -1, -1, -1, -1, -1, -1}; { struct Sideband { Uint32 slot; Uint64 bytes; }; const auto closeAll = [&fds]() { for (int& open : fds) { if (open >= 0) { ::close(open); open = -1; } } }; for (int received = 0; received < 7; ++received) { Sideband sideband{}; Uint64 sidebandSize = 0; int fd = -1; std::vector scratch(Transport::FdPassing::kMaxSidebandBytes); MobileGLMutableByteSpan span{scratch.data(), scratch.size()}; const MobileGLResult got = m_transport->ReceiveFd(&fd, span, &sidebandSize, kHandshakeTimeoutMs); if (got != MOBILEGL_OK || fd < 0) { MGLOG_E("MG_Remote client: segment descriptor %d did not arrive (rc=%d)", received, static_cast(got)); closeAll(); Stop(); return got == MOBILEGL_OK ? MOBILEGL_ERR_PROTOCOL_MISMATCH : got; } if (sidebandSize != sizeof(Sideband)) { MGLOG_E("MG_Remote client: segment offer %d carried %llu sideband bytes", received, static_cast(sidebandSize)); ::close(fd); closeAll(); Stop(); return MOBILEGL_ERR_PROTOCOL_MISMATCH; } std::memcpy(&sideband, scratch.data(), sizeof(Sideband)); const bool isSegment = sideband.slot < 4; if (sideband.slot >= 7 || fds[sideband.slot] >= 0 || (isSegment && sideband.bytes != announced[sideband.slot])) { MGLOG_E("MG_Remote client: segment offer names slot %u with %llu bytes; " "Welcome announced a different size or the slot is already filled", sideband.slot, static_cast(sideband.bytes)); ::close(fd); closeAll(); Stop(); return MOBILEGL_ERR_PROTOCOL_MISMATCH; } fds[sideband.slot] = fd; } // ---- adopt and map. The same Adopt + Map every inproc run has // exercised since P5; only the origin of the descriptor is new. const MobileGLResult adopted = m_shm.AdoptFromDescriptors( fds, announced, replySlotCount, Transport::MemoryRole::Client); if (adopted != MOBILEGL_OK) { closeAll(); Stop(); return adopted; } // The four segment descriptors now belong to m_shm. The two bells // belong to the doorbells, which own and close them. // // WHICH BELL IS WHICH IS THE SESSION'S KNOWLEDGE, exactly as under // inproc: slot 4 is the pair end whose WRITE wakes the server, slot 5 // is the one this side POLLS. // PARK ON ONE END, RING THE OTHER - the same split the server makes, // and for the same reason: a single-fd bell rings the PEER, so it // cannot wake its own waiter. // fds[4] ring the SERVER (ring-only, we never park on it) // fds[5] our park end // fds[6] ring OURSELVES m_socketPeerBell = MakeUnique(-1, fds[4], 1, true); // Built into a TYPED pointer first: the witness is SocketDoorbell's, and the member // is a Doorbell so that the inproc path can put a CondVarDoorbell in it. auto selfBell = MakeUnique(fds[5], fds[6], 1, true); // `dl`: THE DEATH WITNESS, and it has to be a descriptor this side does not also // write to. Our own bell is a socketpair we hold BOTH ends of - slot 5 to park on, // slot 6 to ring ourselves - so it can never hang up however dead the server is. The // control socket's far end is held only by the server, so its hangup IS the death. // // Passing the fd rather than the transport keeps the ownership honest: the bell polls // it for hangup alone and never reads it, and SocketTransport goes on owning and // closing it. CONTRACT-P6 D5c. selfBell->SetDeathWitness(m_socketTransport->StreamFd()); m_socketSelfBell = Move(selfBell); } // `cp`: from here the twelve EGL forwarders cross the socket instead of // being posted into a mailbox this process does not own. Installed BEFORE // FinishStartup, because FinishStartup's caps pump can already trigger // one. Server::ServerLoopInstance().SetRemoteControlSink(&RemoteControlSinkThunk, this); // The apply thread IS ALREADY RUNNING, in the other process: ServerMain // started it before it sent the first descriptor. Starting one here would // put a second applier on a ring that already has one. return FinishStartup(m_socketPeerBell.get(), m_socketSelfBell.get(), false); } MobileGLResult ClientSession::RemoteControlSinkThunk(void* user, Server::SurfaceControlFrame& frame) { return static_cast(user)->RunRemoteSurfaceControlFrame(frame); } MobileGLResult ClientSession::RunRemoteSurfaceControlFrame(Server::SurfaceControlFrame& frame) { if (m_transport == nullptr) { return MOBILEGL_ERR_NOT_INITIALIZED; } // THE CLIENT MINTS, always, dense from 1 (CONTRACT-P6 §6.3). The server's // local mint exists for unnumbered LOCAL posts; a wire request that let // the server renumber it would get back a reply it cannot correlate. if (frame.seq == 0) { frame.seq = ++m_remoteControlSeq; } ::flatbuffers::FlatBufferBuilder builder(512); const MG_Remote::SurfaceWireError encoded = MG_Remote::EncodeSurfaceOpFrame(frame, &builder); if (encoded != MG_Remote::SurfaceWireError::None) { // A NAMED refusal, not a dropped op. InprocOnlyOpOnTheWire is the // live one: three of the twelve forwarders ride the frame channel // without a wire kind (CONTRACT-P6 §2), and a spawn client that // reaches one has found a gap rather than a transport error. MGLOG_E("MG_Remote client: surface op kind %d cannot cross the wire (%s)", static_cast(frame.kind), MG_Remote::SurfaceWireErrorName(encoded)); return MOBILEGL_ERR_UNSUPPORTED; } // ONE CONTROL OP AT A TIME. The twelve forwarders are already // caller-serialised on the server's m_callerMutex under inproc; the same // discipline has to hold here or two replies race for one correlation. const std::lock_guard lock(m_remoteControlMutex); const MobileGLResult sent = m_transport->SendFrame( MobileGLByteSpan{builder.GetBufferPointer(), builder.GetSize()}); if (sent != MOBILEGL_OK) { return sent; } // The reply. A BOUNDED wait whose expiry is NOT fatal (CONTRACT-P6 §5.4 // D5b): a peer that is gone is a different fact from a peer that is slow, // and only the doorbell's death latch may say which. for (;;) { std::vector reply; const MobileGLResult received = ReceiveEnvelope(*m_transport, reply, kRemoteControlTimeoutMs); if (received != MOBILEGL_OK) { // `dl` (D5b): THE EXPIRY IS NOT FATAL AND IT IS NOT ONE ANSWER. The transport // already distinguishes the two states this wait can end in, and the contract // says they must stay distinguished without a threshold: // // TRANSPORT_CLOSED - the control socket returned EOF. Only the server held the // far end, so it is gone. That is the device-loss fact, taken from a // descriptor. // TIMEOUT - nothing arrived in the budget, and the socket is still open. The // peer is ALIVE AND SILENT: wedged, frozen, SIGSTOP'd, or merely slower than // the budget. 5.4 forbids calling that a death, so it gets a NAME instead. // The bell is asked anyway, because a hangup noticed by the ring side counts // here too. if (received == MOBILEGL_ERR_TRANSPORT_CLOSED) { LatchDeviceLost("the control stream reached EOF while a reply was owed"); } else if (const Transport::Doorbell* bell = m_producer.SelfDoorbell(); bell != nullptr && bell->PeerHungUp()) { LatchDeviceLost("a control reply timed out and the peer had hung up"); } else { MGLOG_E("MG_Remote client: the server is ALIVE BUT SILENT - no SurfaceReply " "for %s seq %llu within %u ms and the control stream is still open. " "This is NOT device loss (CONTRACT-P6 5.4): a frozen or merely slow " "peer produces no hangup, and only a hangup may arm the latch. If " "this repeats, the server is wedged rather than gone.", Server::SurfaceControlOpName(frame.kind), static_cast(frame.seq), kRemoteControlTimeoutMs); } MGLOG_E("MG_Remote client: no SurfaceReply for seq %llu within %u ms (rc=%d)", static_cast(frame.seq), kRemoteControlTimeoutMs, static_cast(received)); return received; } const ::MobileGL::Wire::CtrlEnvelope* envelope = ParseEnvelope(reply); if (envelope == nullptr) { return MOBILEGL_ERR_PROTOCOL_MISMATCH; } if (envelope->msg_type() == ::MobileGL::Wire::CtrlMsg::CapsSnapshot) { // The server republishes caps from inside its MakeCurrent // dispatch (a6 row A4-30), so a snapshot can arrive BEFORE the // reply to the very op that produced it. Adopting it here rather // than dropping it is what keeps R-12's "a second arrival IS the // invalidation" true on this path too. if (const auto* snapshot = envelope->msg_as_CapsSnapshot()) { AdoptCapsSnapshot(snapshot); } continue; } if (envelope->msg_type() != ::MobileGL::Wire::CtrlMsg::SurfaceReply || envelope->msg_as_SurfaceReply() == nullptr) { MGLOG_E("MG_Remote client: expected a SurfaceReply, got message type %d", static_cast(envelope->msg_type())); return MOBILEGL_ERR_PROTOCOL_MISMATCH; } const ::MobileGL::Wire::SurfaceReply* wire = envelope->msg_as_SurfaceReply(); if (wire->seq() != frame.seq) { MGLOG_E("MG_Remote client: SurfaceReply names seq %llu, we asked for %llu", static_cast(wire->seq()), static_cast(frame.seq)); return MOBILEGL_ERR_PROTOCOL_MISMATCH; } MG_Remote::DecodeWireSurfaceReply(*wire, &frame); return MOBILEGL_OK; } } // The half of startup that is IDENTICAL for inproc and spawn: the ring // producer, the reply pool, the event ring, the segment table, the encoder, // the first CapsSnapshot and the emitter tables. Extracted by `sm` so the // spawn path cannot drift from the shape P5 spent five packages settling - // everything here is below the line where "which transport" stopped // mattering. // // `startApplyThreadHere` is the one policy difference, and it is not a // transport question: under inproc the client is what starts the server // role, because there is no server-side main to do it. Under spawn there // IS one, and it started the apply thread before it ever sent Welcome. MobileGLResult ClientSession::FinishStartup(Transport::Doorbell* peerBell, Transport::Doorbell* selfBell, bool startApplyThreadHere) { Transport::RingControl* control = m_shm.CmdControl(); m_cmd = Transport::RingProducer(control, m_shm.CmdRingBase(), m_shm.CmdRingCapacity(), Transport::RingCursorSet::Cmd); // NO STAGE RING. SEG_STAGE is package w1's encoder-local LINEAR ALLOCATOR: // a staged byte run carries no RingRecordHeader, nothing consumes SEG_STAGE, // and the allocator reclaims on retiredSeq. A RingProducer over // RingCursorSet::Stage would publish stageHead with nothing advancing the // two tails, so FreeBytes() would fall to zero the first time the head // lapped the capacity and never recover - a guaranteed hang. See // RingControl's stage triple in Ring.h. if (!m_cmd.Valid()) { Stop(); return MOBILEGL_ERR_INVALID_ARGUMENT; } // PeerDoorbell() is the bell the SERVER parks on and this side rings; SelfDoorbell() is // this side's own. Which is which is the session's knowledge, not the transport's. m_producer.Attach(control, &m_cmd, peerBell, selfBell, SpinUsFromConfig()); m_replies = Transport::ReplySlotPool(m_shm.ReplyBase(), m_shm.ReplyBytes(), m_shm.ReplySlotCount()); m_events = Transport::EventRingConsumer(m_shm.EventControl(), control, m_shm.EventRingBase(), m_shm.EventRingCapacity(), m_shm.EventSegmentBase()); // P5e (ra, §2.6): the drain's other half. A server parked on a full SEG_EVENT is woken // by the drain that emptied it, not by the next thing this client happens to publish. m_events.SetServerDoorbell(peerBell); if (!m_replies.Valid() || !m_events.Valid()) { Stop(); return MOBILEGL_ERR_INVALID_ARGUMENT; } m_barrierArmed = VerbBarrierFromConfig(); if (!m_barrierArmed) { MGLOG_W("MG_Remote client: MOBILEGL_IPC_VERB_BARRIER=0 - this is R-1's NEGATIVE " "CONTROL and is EXPECTED to be red. 31 of the 63 PipeInputs fields are still " "pulled from a live GLContext by the client's residual fill, so a free-running " "queue lets the server read a FUTURE value of them"); } // ---- 6. the client's segment table. IT MUST NOT INSTALL THE PROCESS RESOLVER: there is // exactly one gMGPipeSegmentResolver per process, the SERVER role owns it (table 3), and // the client never resolves a span at all - it only ever writes Ptr = nullptr (R-2's // rule B). Two roles racing on that one inline variable is precisely what the server's // InstallProcessResolver asserts against. // // The Install calls themselves are package w1's and are named-Fatal stubs until w1 // lands; see ServerSession::Accept for why they are called anyway. m_segments.Install(Wire::kSegCmd, Wire::SegmentView{m_shm.CmdRingBase(), m_shm.CmdRingCapacity()}); m_segments.Install(Wire::kSegStage, Wire::SegmentView{m_shm.StageBase(), m_shm.StageBytes()}); m_segments.Install(Wire::kSegReply, Wire::SegmentView{m_shm.ReplyBase(), m_shm.ReplyBytes()}); m_segments.Install(Wire::kSegEvent, Wire::SegmentView{m_shm.EventSegmentBase(), m_shm.AnnouncedSize(Transport::SessionSegmentSlot::Event)}); // nullptr for the stage producer, and that is the honest value: c0's // signature predates w1's ruling that SEG_STAGE is a linear allocator, and // the encoder reaches its bytes through the SegmentTable above. Handing it // a live RingProducer over a cursor triple nobody consumes would be the // half-wired shape this session exists not to have. m_encoder = Wire::PipeWireEncoder(control, &m_cmd, nullptr, &m_segments); m_encoder.SetStageRetirementDoorbell(m_producer.SelfDoorbell()); // P5e (ra, §2.6): the stage bell is a client wait and every client wait drains. The // encoder owns the park; the session owns SEG_EVENT, so the session lends it a drain. m_encoder.SetStageWaitHook( +[](void* self) { DrainEventRing(static_cast(self)->Events()); }, this); // ---- 7. the first CapsSnapshot, if the server had a backend to publish one from. // ONE DRAIN, ONE ADOPTER (c1): PumpControlPlane below is the only thing in the client // that turns a CapsSnapshot into a CapsMirror generation, so R-12's "a second arrival // IS the invalidation" lives in exactly one place. s1's step here used to stop at // "the snapshot arrived and is verifiable"; it now goes all the way, through the same // function every later arrival goes through. if (PumpControlPlane() == 0) { MGLOG_W("MG_Remote client: the handshake carried no CapsSnapshot - the caps mirror is " "a PLACEHOLDER until one arrives. Every caps read until then answers a " "default and says so"); } // BOTH, AND m_started FIRST. `Active()` is what the integration lane's skip reads and // `m_started` is what `EmitAndWait` reads, and c1's round-1 rewrite of this function // set only the second of the two - so a fully-handshaken session with an apply thread // running and a caps mirror adopted took Fatal{NoClientSession, "Clear"} on its very // first verb, which is the most confusing possible spelling of "the session is up". // The order matters for the same reason it does at the other end: `Active()` hands a // caller a session it may immediately emit on, so the flag that permits emitting has // to be true before the pointer that grants access to it is published. `Stop()` takes // them down in the mirror order (m_started = false, then g_active = nullptr). m_started = true; g_active = this; // D1c: THE SESSION-LIVE AND APPLY-THREAD FACTS, stated where they become true. // // MGPipeApplierReset's layer-2 guard used to ask ClientSession::Active() != nullptr, // which no SERVER process can ever answer yes to - so the guard was compiled in and // permanently disarmed there. MGPipeSessionLive() is the same question asked in a way // both roles can answer, and the probe hands MG_Backend the one thread fact it cannot // derive: whether the CALLING thread is inside the applier. MG_Pipe::MGPipeSetSessionLive(true); // ServerLoop::OnApplyThread, NOT ApplyThreadIsInsideApplier - two different questions, // and installing the wrong one turned every GL-thread call into a role violation while // the applier happened to be busy. Measured: the inproc retrace died on // Fatal{RoleViolation, "MGPipeSlots"} raised from the GL thread. "Am I the apply // thread" is what the guards ask; "is the apply thread inside the applier" is R-1's // mutual-exclusion probe and is about a DIFFERENT thread. MG_Pipe::MGPipeSetApplyThreadProbe(&Server::ServerLoop::OnApplyThread); LogMemory("handshake"); // ---- 8. and only now the apply thread. It is package v1's ServerLoop: it names the // thread mgl-srv-apply, applies MOBILEGL_IPC_SERVER_AFFINITY and logs the RESOLVED // mask. Under `inproc` the client is what starts the server role, which is why this // call is here rather than in some server-side main. if (startApplyThreadHere) { Server::ServerSession& server = Server::ServerSessionInstance(); const MobileGLResult running = Server::ServerLoopInstance().Start(server); if (running != MOBILEGL_OK) { Stop(); return running; } } // ---- 9. AND ONLY NOW THE THIRTY-SEVEN WIRE EMITTERS (R-17). This is the line that // arms `integration-split`: the 21 `DirectGLES.Split.*` entries skip on // `ClientSession::Active() == nullptr`, and every one of the four arming facts is true // at exactly this point and at no earlier one. // // IT IS LAST, AND EACH OF THE FOUR REASONS IS A DIFFERENT FAILURE: // - after Hello/Welcome (step 2-4), or an emitter would publish into a ring the peer // has not mapped; // - after the first CapsSnapshot (step 7), because R-8's liveness gates read the caps // mirror and a PLACEHOLDER mirror consumes nothing - a record emitted before it // would go to a server this client has not been told consumes that family; // - after ServerLoop::Start (step 8), because EmitAndWait BLOCKS on appliedSeq and // with no apply thread nothing advances it: the first resource_create would spend // the whole kBarrierTimeoutMs in the barrier and then Fatal{BarrierTimeout}; // - on THIS thread, the one that called MG_Backend::Init(), because it is the GL // thread and table 3 makes gPipeInputs its to touch while the barrier holds. // The publication is safe without a fence because the apply thread never reads these // tables - the server decodes straight into MGPipeApply* - and this thread wrote them // before it can reach any GL entry point. InstallClientWireTables(); return MOBILEGL_OK; } void ClientSession::Stop() { // FIRST, BEFORE ANYTHING ELSE GOES AWAY (R-17 / codex 4). Every later step here frees // something an emitter dereferences - the rings, the segments, the transports - so a // routed GL-thread call that arrives during teardown must not run the applier on the // caller and must not reach a half-freed ring. Round 2 reinstalled the monolith adapters // HERE, which is running the applier on the caller - the forbidden path table 3 draws. // Uninstall now RAISES A FLAG and leaves the wire rows in place; the next routed call // aborts by name (Fatal{ClientTablesUninstalled}) inside RequireSession before it touches // anything. The monolith adapters go back only at the END of teardown // (ReinstallMonolithAfterTeardown), for the at-exit ~BufferObject deletes that reach a // process with no session at all - and by then the rings are gone, so the applier a // monolith adapter runs is a defined no-op rather than a use-after-free. UninstallClientWireTables(); if (!m_started) { // Start's own failure paths land here with a half-built session. FIVE of them are // reached AFTER ServerSession::Accept has already returned OK, so tearing down // only the client half is not enough and gets three things wrong at once: the // server keeps its four mappings and stays m_accepted, so Accept's own guard // refuses every later Start and the process can never open a session again; the // process-wide segment resolver stays installed; and resetting m_serverTransport // destroys a transport that ServerSession::m_transport and its two Doorbell* // still point at. The server closes FIRST, in the same order the started path // gets right, and only then do the transports go. m_producer.Detach(); m_shm.Close(); Server::ServerSessionInstance().Close(); m_clientTransport.reset(); m_serverTransport.reset(); m_transport = nullptr; // The rings are gone; put the monolith adapters back for a process that will make no // more routed calls except, possibly, at-exit deletes (codex 4). ReinstallMonolithAfterTeardown(); return; } // TABLE 3's TEARDOWN ORDER, and every step of it is load-bearing. // // 1. publish and let the server drain. Bounded: a lost record must be a red lane, not // a hung exit. Transport::RingControl* control = m_shm.CmdControl(); if (control != nullptr) { // The PRODUCER's own last-published seq, not RingControl::submittedSeq. Ring.h:72-77 // permits submittedSeq to be published lazily and Ring.h:243 encourages batching // the publish, so the shared watermark is allowed to lag the emitter - and a drain // that waited for `appliedSeq >= submittedSeq` would then under-wait and free an // emitter-owned var-tail while a record still names it. With the verb barrier armed // the two are equal; under MOBILEGL_IPC_VERB_BARRIER=0, R-1's negative control that // the phase has to run once, they are not. const Uint64 submitted = m_producer.LastPublishedSeq(); m_producer.PublishAndNotify(submitted); if (submitted != 0 && m_producer.WaitForApplied(submitted, kDrainTimeoutMs) != Transport::SessionWait::Reached) { MGLOG_E("MG_Remote client: the server did not drain to seq %llu within %u ms; " "tearing down anyway, and anything an emitter still owns is freed below " "AFTER the join, which is what keeps that from being a use-after-free", static_cast(submitted), kDrainTimeoutMs); } } // 2. Doorbell::Kill(). THE ONLY thing that can wake an apply thread parked on // kWaitForever (CondVarDoorbell::Kill): a Notify is consumed by one Park, after which // Doorbell::Wait re-tests a condition nothing published, finds the bell alive and // parks again, forever. InProcessChannel::Close kills both bells. if (m_transport != nullptr) { m_transport->Shutdown(); } // 3. JOIN, bounded - package v1's ServerLoop::Stop, which also destroys the server's // private BackendObject on that thread before it exits. Server::ServerLoopInstance().Stop(); // 4. and ONLY NOW may anything an emitter owns be released: a var-tail still // referenced by an unapplied record is a use-after-free the join is what prevents. LogMemory("teardown"); LogWireLedger(); m_producer.Detach(); m_encoder = Wire::PipeWireEncoder(); m_events = Transport::EventRingConsumer(); m_replies = Transport::ReplySlotPool(); m_cmd = Transport::RingProducer(); m_shm.Close(); Server::ServerSessionInstance().Close(); m_clientTransport.reset(); m_serverTransport.reset(); m_transport = nullptr; m_started = false; // P5e (ra, §1): the LATCH GOES DOWN WITH THE SESSION. It is "what the first caps // adoption of THIS session said", and a session that starts again - the same singleton, // a second Start - must take it again from that session's own first snapshot rather // than inherit a verdict about a server that is gone. The present serial goes with it // for the same reason: the credit's id space belongs to the session that paces on it. m_runAheadArmed = false; m_runAheadLatched = false; m_presentsSent = 0; if (g_active == this) { g_active = nullptr; // Mirror order, and the probe goes with it: a dangling function pointer into a // torn-down session is a worse answer than "there is no applier". MG_Pipe::MGPipeSetSessionLive(false); MG_Pipe::MGPipeSetApplyThreadProbe(nullptr); } // AND ONLY NOW the monolith adapters go back (codex 4): every ring an emitter would have // used is freed above, so from here a routed call - an at-exit ~BufferObject delete - runs // the applier exactly as it does under monolith, which is the correct answer for a // process that no longer has a session. During the whole span above, the raised flag made // any routed call abort by name instead. ReinstallMonolithAfterTeardown(); // P6: the server we launched. Closing the transport is what it sees as // EOF and EOF is its whole exit condition, so the order is transport // first, reap second - and the reap is BOUNDED, because a server that // will not go is a different fact from one that is slow and only the // exit code can say which. if (m_spawned.pid >= 0) { if (m_socketTransport) { m_socketTransport->Shutdown(); } int exitCode = 0; if (Server::ReapServer(m_spawned, 3000, &exitCode) != MOBILEGL_OK) { MGLOG_W("MG_Remote client: the spawned server (pid %d) did not exit within 3 s", m_spawned.pid); } else if (exitCode != 0) { MGLOG_W("MG_Remote client: the spawned server exited %d", exitCode); } } Server::ServerLoopInstance().SetRemoteControlSink(nullptr, nullptr); } Wire::PipeWireEncoder& ClientSession::Encoder() { return m_encoder; } CapsMirror& ClientSession::Caps() { return CapsMirrorInstance(); } // PACKAGE c1's. The barrier's wait and the reply's wait are ONE wait (R-3/R-5), which is // what makes a blocking ReadPixels, MapPersistent's decline and the four Bool acceptances // cost zero extra round trips - and the client may not re-derive any of those four answers // locally. s1 supplies the four primitives it composes from: Encoder(), Producer(), // WaitForApplied() and ReadReply(). // // THE ORDER IS ENCODE -> PUBLISH+NOTIFY -> WAIT -> READ REPLY, and it is not negotiable. // Splitting the wait from the read is how a package ends up answering an acceptance // question locally, which is the c0f/c0g defect P4a paid two contract corrections for; and // "always accept" is ID-39's 66 lost DirectVulkan uploads with a wire in between. Uint64 ClientSession::EmitAndWait(MG_Pipe::MGPWireOp op, const void* payload, Uint64 payloadBytes, const void* varTail, Uint64 varTailBytes, void* replyOut, Uint64 replyBytes, Int32* statusOut, Uint64* replySizeOut) { // One tail is the two-tail form with one entry (P5b d1). Nothing is duplicated: the // barrier policy below has exactly one body, and the encoder's one-tail EncodeRecord is // itself defined as the tails form with tailCount <= 1. const Wire::WireTail tail{varTail, varTailBytes}; return EmitAndWaitTails(op, payload, payloadBytes, varTail != nullptr ? &tail : nullptr, varTail != nullptr ? 1u : 0u, replyOut, replyBytes, statusOut, replySizeOut); } Uint64 ClientSession::EmitAndWaitTails(MG_Pipe::MGPWireOp op, const void* payload, Uint64 payloadBytes, const Wire::WireTail* tails, Uint32 tailCount, void* replyOut, Uint64 replyBytes, Int32* statusOut, Uint64* replySizeOut, Bool wantReply) { Uint64 varTailBytes = 0; for (Uint32 i = 0; i < tailCount; ++i) varTailBytes += tails[i].Size; if (statusOut != nullptr) *statusOut = Wire::ReplySink::kStatusError; if (replySizeOut != nullptr) *replySizeOut = 0; if (!m_started) { SessionFail(MGFatalFamily::NoClientSession, "MGPipe: Fatal{NoClientSession, \"%s\"} - EmitAndWait on a session that has " "not started. There is no fall-through: a record that could not be emitted " "is a verb that did not happen", Wire::WireOpName(op)); } // R-1's INVARIANT, AS A RUNTIME CHECK RATHER THAN A SENTENCE - but only while the // per-record barrier is the fencing model. With MOBILEGL_IPC_BATCH_WAITS=1 the // intended shape is exactly "the apply thread is inside the applier while the GL // thread emits the next value record", and the check would fire on the first batch: // its protection is already carried by the disjoint-field model (record-supplied // fields are written only by the applier from records and are never filled by the // client while the wire is live; BARRIER-PULLED fields are written only by the // client's residual fill and are read only at the pull-verbs, whose own wait makes // the fill quiescent during that apply). With the batch off, the original check // stands as written. if (MG_Config::Ipc.BatchWaits == 0 && ApplyThreadIsInsideApplier()) { SessionFail(MGFatalFamily::BarrierViolation, "MGPipe: Fatal{BarrierViolation, \"%s\"} - the apply thread is inside the " "applier while the GL thread is emitting. R-1 makes at most one of them " "runnable, which is what keeps one process-wide gPipeInputs legal", Wire::WireOpName(op)); } Uint64 seq = m_encoder.EncodeRecord(op, payload, payloadBytes, tails, tailCount); if (seq == Wire::kInvalidSeq) { // EncodeRecord already refused individually oversized records. This one fits, // but appliedSeq may be ahead of retiredTail: wait for actual reclaimable space, // including the wrap pad. Prior EmitAndWait calls have published every record. Wire::WireRecordLayout layout{}; Wire::MGPipeWireRecordLayout(op, payload, layout); const Uint64 toEnd = m_cmd.Capacity() - m_cmd.LocalHead() % m_cmd.Capacity(); const Uint64 needed = layout.TotalBytes + (toEnd < layout.TotalBytes ? toEnd : 0); const BarrierWaitScope waiting; const auto wait = m_producer.WaitForCmdSpace(needed, kBarrierTimeoutMs); if (wait != Transport::SessionWait::Reached) { SessionFail(MGFatalFamily::RetirementWaitFailed, "MGPipe: Fatal{RetirementWaitFailed, \"SEG_CMD\"} - %s needs %llu " "reclaimable bytes; the retirement wait ended on %s", Wire::WireOpName(op), static_cast(needed), wait == Transport::SessionWait::ShutDown ? "shutdown" : "timeout"); } // P5e (ra, §2.6): EVERY WAIT EXIT DRAINS. The cmd-space wait is one of the two // safety nets under run-ahead (the stage bell is the other), and a client parked // here without draining is the other half of the flow-control deadlock: the server // stops applying on a full event ring, so retiredSeq stops moving, so this wait // never completes. One drain on the way out closes it. DrainEventRing(m_events); seq = m_encoder.EncodeRecord(op, payload, payloadBytes, tails, tailCount); if (seq == Wire::kInvalidSeq) { SessionFail(MGFatalFamily::RingOverrun, "MGPipe: Fatal{RingOverrun, \"SEG_CMD\"} - %s refused after sufficient " "space retired", Wire::WireOpName(op)); } } // Publish the head, record submittedSeq, THEN ring - in that order, which is // SessionProducer's one job and RingTest.cpp:446's pin. Notify-then-publish loses the // wakeup. m_producer.PublishAndNotify(seq); // Does this row own a reply slot? kMGPipeCallFlags IS THE SINGLE SOURCE OF TRUTH // (R-16 / ID-31) - fourteen rows now, because the four Bool acceptance entry points // gained the flag. Asking the catalogue rather than the caller is what stops a caller // that forgot to pass a buffer from silently turning an answer into a guess. // // P5e (ra) ADDS ONE CALLER-SIDE CONJUNCT AND NO SECOND SOURCE OF TRUTH: `wantReply`. // The catalogue still says which rows CARRY a slot; a caller that passes false says it // will not read this one (resource_subdata's buffer half, §2.5 - the only such caller). // The server posts either way, so the wire is unchanged and the row keeps its flag. const Bool rowCarriesReplySlot = (MG_Pipe::MGPipeCallFlagsFor(op) & static_cast(MG_Pipe::kReplySlot)) != 0; const Bool ownsReplySlot = rowCarriesReplySlot && wantReply; // ---- THE P5e WAIT RULE (CONTRACT-P5E §2.3) --------------------------------------- // // IT STANDS BEFORE THE BATCH SKIP AND REPLACES IT WHOLE, rather than adding a clause to // it. The batch skip is a rule about CALL CLASSES ("a set/CSO/object record carries its // whole value in the payload"); the wait class is a rule about WHAT AN APPLY READS, and // it is generated from the same PipeCalls.def row the class comes from // (MGPipeWaitClassFor, c0e). Two rules layered would mean a row could be skipped by the // class rule and waited by the column, and the column is the one the SERVER's // MGPipeBarriered reads - so the two halves of the wire would disagree about whether // the client is parked behind this record, which is the single fact §4.4's exemptions // and §3.3's detector both rest on. // // WITH RUN-AHEAD DISARMED NOTHING BELOW RUNS and the file's original shape continues - // Magma, a monolith transport, MOBILEGL_IPC_RUN_AHEAD=0 and every build before the // integration commit take the old path byte for byte, which is what makes RUN_AHEAD=0 // a pure wait-rule A/B on identical server code (§5.8). if (RunAheadArmed() && !ownsReplySlot) { MG_Pipe::MGPipeWaitClass waitClass = MG_Pipe::MGPipeWaitClassFor(op); // §2.5's named relaxation, and the ONLY place a static column answer is overridden: // a kWaitReply row whose caller does not want the answer has nothing to wait FOR. // It is spelled here rather than in the column because the column is per-op and // this is per-call - the texture half of the same row still waits. if (!wantReply && waitClass == MG_Pipe::kWaitReply) waitClass = MG_Pipe::kWaitNone; switch (waitClass) { case MG_Pipe::kWaitNone: // Published and gone. Rule F is what makes this legal: the apply of an // unbarriered record names no client memory at all (§3, §4, §5). return seq; case MG_Pipe::kWaitPresent: // The credit wait already ran, in EmitPresent, BEFORE the encode (§2.4). There // is nothing left to do here and waiting would turn the credit into a barrier. return seq; case MG_Pipe::kWaitApplied: // §2.2's barriered rows: applier_reset, generate_mipmap (until tx2 lands its // VerbMipRes arm), set_storage_block_binding, the five XFB span verbs and the // CopyTex endpoint. Each still pulls a row or probes a registry, and the // client's wait is exactly what keeps P5C's semantics legal for it (ruling 4). // Falls through to the wait below. break; case MG_Pipe::kWaitReply: case MG_Pipe::kWaitClassCount: // Unreachable: `ownsReplySlot` is false here, and gen_pipe refuses a kWaitReply // row without a slot and a kReplySlot row that does not wait (c0e's three // negative controls). Treated as a wait rather than as a skip, because the // wrong answer to "does this hang" is the recoverable one. break; } } // MOBILEGL_IPC_BATCH_WAITS (default 1): a value-class record is published WITHOUT // waiting for its own apply. The barrier's one load-bearing reader is the backend // sync that pulls the object-class fields (CONTRACT-P5 table 2's remaining rows), and // those reads happen only at the kCtxVerb applies - draw / clear / blit / dispatch / // readback / XFB - plus the screen and query classes, and EVERY ONE OF THOSE STILL // WAITS below (as does every reply-slot row, whose answer the client consumes). So // the pulled fields are exactly as fresh at every read point as the per-record // barrier made them: between two waits the residual fill may move a field, but no // apply is reading it in between, and the next pull-verb's fill runs after the // previous pull-verb's apply was waited - the R-1 fence posts are untouched, only // the number of round trips between them changes. Set / CSO / object records carry // their whole value in the payload and are consumed at the next (waited) sync, which // the in-order ring guarantees precedes that sync's apply. // // WITH ONE NAMED EXCEPTION, FOUND BY ITS OWN FATAL (P5d round 3): generate_mipmap is // catalogued kCtxObject, but its sink is the backend's GenerateMipmap, which reads // MGB_CTX->GetActiveTextureUnit() and the unit's binding slot - residual inputs whose // freshness is the CLIENT's verb serial. Published without a wait, the record sits in // the ring while this thread runs the next verb's MGPipeValidateForVerb, which moves // that serial and withdraws the server stamp; the apply thread then reads the unit // stale and aborts Fatal{UnmigratedPipeInput, "GetActiveTextureUnit@"} // (F1WireScenario.GenerateMipmap*Pixels, reproduced on the gate tree once the round-3 // spin no longer read the clock). So it waits, exactly as the four stamping verbs // do. The rule this encodes: a record may skip its wait only if its apply reads // nothing the residual fill writes - the value carried in the payload is the whole // input. No other kCtxObject row has a backend body behind it that reads MGB_CTX // (set_texture_params, resource_subdata, resource_readback and get_texture_image // own reply slots and wait anyway; the resource_* transfers and object_death resolve // by handle). // // AND IT IS DISARMED WHOLE UNDER RUN-AHEAD (P5e §2.3): the wait class above is the // rule there, and a second skip rule reading a different column is how the two halves // of the wire end up disagreeing about whether the client is parked. Written as a // conjunct here rather than left to "the classes happen not to overlap", because the // classes overlapping is one PipeCalls.def row away at any time. if (!ownsReplySlot && !RunAheadArmed() && MG_Config::Ipc.BatchWaits != 0 && op != MG_Pipe::MGPWireOp::GenerateMipmap) { const MG_Pipe::MGPipeCallClass callClass = MG_Pipe::MGPipeCallClassFor(op); if (callClass == MG_Pipe::kCtxState || callClass == MG_Pipe::kCtxCso || callClass == MG_Pipe::kCtxObject) { return seq; } } if (!m_barrierArmed && !ownsReplySlot) { // R-1's NEGATIVE CONTROL ARM, and the only thing it turns off is the barrier. A // reply-slot row still waits: the answer is not derivable here and R-5 forbids // inventing one, so MOBILEGL_IPC_VERB_BARRIER=0 makes the queue free-running, not // the client clairvoyant. return seq; } const BarrierWaitScope waiting; const Uint64 waitBudgetMs = AppliedWaitBudgetMs(op, payload); const Transport::SessionWait wait = WaitForAppliedBudget(m_producer, m_events, seq, waitBudgetMs); if (wait == Transport::SessionWait::ShutDown) { // `dl`: SHUTDOWN IS TWO DIFFERENT EVENTS AND ONLY ONE IS A DEVICE LOSS. Stop() kills // this same bell to wake a parked applier, so an orderly exit arrives here too - and // latching on Dead() alone would arm the device-lost flag on every clean teardown. // PeerHungUp() is true only when a descriptor whose far end ONLY THE SERVER held // reported hangup, which nothing this side can cause. if (const Transport::Doorbell* bell = m_producer.SelfDoorbell(); bell != nullptr && bell->PeerHungUp()) { LatchDeviceLost("the barrier woke on a peer that had hung up"); } // The doorbell died: the server went away. The only thing that returns from a // kWaitForever park, and therefore the only way a client blocked in the barrier // survives a server that is gone. It is teardown, not a server fault - so the answer // handed back is DECLINED, not the ERROR the status was pre-set to (M4): a // reply-owning row that saw ERROR here would abort Fatal{ReplyError} on a shutting- // down session, which is what the "teardown legitimately reaches here" comment // promised would NOT happen. DECLINED is honest - "the verb did not happen" - and the // acceptance rows already treat it as `false` / nullptr without aborting. // ReadPixels intentionally refuses this with Fatal{ReadbackDeclined, "ReadPixels"}: // unlike acceptance rows it cannot return successfully without complete pixels. if (statusOut != nullptr) *statusOut = Wire::ReplySink::kStatusDeclined; MGLOG_E("MG_Remote client: the barrier for %s (seq %llu) woke on a dead doorbell; the " "server is gone and this verb did not happen (reported as DECLINED, not ERROR)", Wire::WireOpName(op), static_cast(seq)); return seq; } if (wait != Transport::SessionWait::Reached) { SessionFail(MGFatalFamily::BarrierTimeout, "MGPipe: Fatal{BarrierTimeout, \"%s\"} - appliedSeq did not reach %llu within " "%llu ms. A bounded wait is deliberate: a wedged CI job and a lost record look " "identical from outside, and only one of them is a bug worth finding", Wire::WireOpName(op), static_cast(seq), static_cast(waitBudgetMs)); } // THE REVERSE CHANNEL IS DRAINED HERE, and here is the only place it can be: under the // barrier this is the one instant at which the apply thread is known not to be inside // the applier, and MGPipeClientOnGpuWritten / OnBufferWriteback write frontend objects. // It is what gives AwaitBufferWriteback something to have waited FOR: b1's third state // clears when the writeback lands, and the writeback lands on this ring. DrainEventRing(m_events); if (!ownsReplySlot) { // P5e (ra, §2.3). A barriered record's apply has returned and this thread is the // only runnable one, so the four O-class SharedPtr rows the last fill left in // gPipeInputs can go. Under lockstep they were harmless - the next verb's fill // overwrote them - but under run-ahead the next fill may be a frame away, and a // frontend VAO or program whose LAST owner is that block is one the apply thread // can end up destroying (§2.7's deferred-destroy hazard). Released here rather // than at the fill, because here is where the applier is provably idle. if (RunAheadArmed()) ReleaseFillPins(); return seq; } // THE SAME WAIT, NOT A SECOND ONE. appliedSeq >= seq already means the server wrote // this record's answer, because it writes the slot before it advances the watermark. Uint64 replySize = 0; Int32 status = Wire::ReplySink::kStatusError; if (!ReadReply(seq, replyOut, replyBytes, &status, &replySize)) { SessionFail(MGFatalFamily::ReplyMissing, "MGPipe: Fatal{ReplyMissing, \"%s\"} - seq %llu carries kReplySlot and the " "server applied it, but its slot does not stamp that seq. The stamp is what " "makes a wrong-slot read detectable rather than plausible (R-3)", Wire::WireOpName(op), static_cast(seq)); } if (statusOut != nullptr) *statusOut = status; if (replySizeOut != nullptr) *replySizeOut = replySize; if (status == Wire::ReplySink::kStatusError) { MGLOG_E("MG_Remote client: %s (seq %llu) answered ERROR", Wire::WireOpName(op), static_cast(seq)); } // DECLINED is NOT an error and is deliberately not logged as one: it is how // MapPersistent says nullptr (R-6) and how the four Bool acceptance rows say false // (R-5). A client that treated it as a failure would re-create ID-39 from the other // side. if (replyOut != nullptr && replySize > replyBytes) { SessionFail(MGFatalFamily::ReplyTooLarge, "MGPipe: Fatal{ReplyTooLarge, \"%s\"} - the answer is %llu bytes and the " "caller offered %llu. P5 does not chunk a reply", Wire::WireOpName(op), static_cast(replySize), static_cast(replyBytes)); } return seq; } Bool ClientSession::BarrierArmed() const { return m_barrierArmed; } // ---- P5e (ra): the run-ahead latch and the waits it leaves behind --------------------- Bool ClientSession::RunAheadArmed() const { return m_runAheadArmed; } // THE PRODUCER'S NUMBER, NOT A SECOND ONE. SessionProducer records it inside // PublishAndNotify, which is the only place a record becomes published at all; a copy kept // here would be a second thing to forget to write on a path that publishes without waiting // - which, after P5e, is most of them. Uint64 ClientSession::LastPublishedSeq() const { return m_producer.LastPublishedSeq(); } Uint64 ClientSession::PresentCreditWaits() const { return m_presentCreditWaits; } void ClientSession::LatchRunAheadFromCaps() { #if MOBILEGL_BUILD_DISAGGREGATED // THE CONJUNCTION, IN THE CONTRACT'S OWN ORDER (§1). The caps read is last because it // is the only one that costs a mirror load, and because it is the only one whose answer // can change: the other two are process configuration. const Bool armed = m_barrierArmed && MG_Config::Ipc.RunAhead != 0 && Caps().HasCap(MG_Pipe::kCapRunAheadApply); if (!m_runAheadLatched) { m_runAheadLatched = true; m_runAheadArmed = armed; if (armed) { MGLOG_I("MG_Remote client: run-ahead ARMED - the server publishes " "kCapRunAheadApply, so an unbarriered record is published and not " "waited for (CONTRACT-P5E §2.3), paced by a present credit of %u", MG_Config::Ipc.PresentCredit); } else if (MG_Config::Ipc.RunAhead != 0 && m_barrierArmed) { // THE KNOB IS NOT A SWITCH ON ITS OWN, and an operator who set it has to be // told that, or an A/B arm that measured nothing looks like an A/B arm that // measured no difference (Config.h's own argument for parsing it everywhere). MGLOG_W_ONCE("MG_Remote client: run-ahead requested, server does not publish " "kCapRunAheadApply - running lockstep. The selected backend has " "not enabled its implementation-readiness gate"); } return; } // A LATER SNAPSHOT MAY ONLY TURN IT OFF. Promotion would mean the records already // published without a wait were published against a rule this server never agreed to, // and nothing can un-publish them; demotion only adds waits, so it is safe and is // exactly what a server that lost its run-ahead backend needs. if (!armed && m_runAheadArmed) { m_runAheadArmed = false; MGLOG_W("MG_Remote client: run-ahead DISARMED by a later caps snapshot - every " "record from here waits as it did before P5e"); } #endif } void ClientSession::ReleaseFillPins() { #if MOBILEGL_BUILD_DISAGGREGATED // The block's own door: PipeInputs' O-class rows are private and MG_Remote has no // business knowing which four they are. MG_Impl owns the fill, so MG_Impl owns the // un-fill (PipeFill.cpp), and this side only says WHEN. MG_Pipe::MGPipeReleaseResidualFillPins(); #endif } void ClientSession::WaitForApplyToCatchUp(const char* why) { #if MOBILEGL_BUILD_DISAGGREGATED // A NO-OP WITHOUT RUN-AHEAD, and that is not an optimisation: under lockstep the // client already waited for every record it published, so appliedSeq is at // LastPublishedSeq by construction and a second wait would only add a park on a // watermark that is already reached. if (!m_runAheadArmed || !m_started) return; const Uint64 target = m_producer.LastPublishedSeq(); if (target == 0) return; const BarrierWaitScope waiting; const Transport::SessionWait wait = WaitForAppliedBudget(m_producer, m_events, target, kBarrierTimeoutMs); if (wait == Transport::SessionWait::ShutDown) { // `dl`: the same two-events distinction the verb barrier makes. Only a peer that // HUNG UP is a device loss; Stop() reaches this path too. if (const Transport::Doorbell* bell = m_producer.SelfDoorbell(); bell != nullptr && bell->PeerHungUp()) { LatchDeviceLost("a forced run-ahead wait woke on a peer that had hung up"); } // The doorbell died: teardown, not a fault - the same answer EmitAndWaitTails // gives, and for the same reason. MGLOG_E("MG_Remote client: the forced wait for %s woke on a dead doorbell; the " "server is gone", why); return; } if (wait != Transport::SessionWait::Reached) { SessionFail(MGFatalFamily::BarrierTimeout, "MGPipe: Fatal{BarrierTimeout, \"%s\"} - a forced run-ahead wait did not " "reach appliedSeq %llu within %u ms. The forced waits of CONTRACT-P5E §2.5 " "are the points at which the client must be in step again, so a wedge here " "is a wedge in the queue and not a slow frame", why, static_cast(target), kBarrierTimeoutMs); } // EVERY WAIT EXIT DRAINS (§2.6). It is what the flow-control deadlock argument rests // on: the server blocks only on a full event ring, the client blocks only on // watermarks the server advances, and every client wait drains - so at most one side // is parked at any instant. DrainEventRing(m_events); ReleaseFillPins(); #else (void)why; #endif } void ClientSession::Finish() { // glFinish. Nothing goes on the wire - Flush and Finish have no record (ARCHITECTURE // §11) - and under lockstep there was nothing to do, because the client had already // waited out every command it issued. Under run-ahead "the commands issued so far have // completed" is a promise the queue can break, so this is where it is kept. WaitForApplyToCatchUp("glFinish"); } Uint64 ClientSession::AcquirePresentCredit() { #if MOBILEGL_BUILD_DISAGGREGATED // MINTED CLIENT-SIDE AND 1-BASED (§1). Before P5e the client sent 0 and the server // stamped its own frame count, which was honest while nothing paced on it; a credit // needs an id space only the payer can advance, and that is this one. const Uint64 serial = m_presentsSent + 1; if (!m_runAheadArmed) { // Lockstep: the present record's own barrier IS the pacing, one frame deep by // construction. The serial is still minted so that both arms carry the same field // and the server's ReturnPresentCredit is fed on both - an A/B whose two arms // disagree about a wire field is not an A/B (Config.h's rule for the knobs). m_presentsSent = serial; return serial; } const Uint32 credit = MG_Config::Ipc.PresentCredit; if (m_presentsSent >= credit) { // Wait for swap number (serial - credit) to have come back. With credit 1 that is // the previous frame: the client publishes frame N+1's records while the server // applies and swaps frame N, and blocks here until swap N returned - one frame of // overlap and at most one frame of added latency (ruling 4 / ID-92). const Uint64 awaited = serial - credit; ++m_presentCreditWaits; const BarrierWaitScope waiting; Transport::SessionWait wait = Transport::SessionWait::TimedOut; for (;;) { // §2.6 again: the credit park breaks on a full SEG_EVENT too, for the same // reason the applied park does - a server that stopped producing stopped // swapping, and this waiter holds the only drain. wait = m_producer.WaitForPresentAckOrEventBacklog(awaited, kBarrierTimeoutMs); if (wait != Transport::SessionWait::Reached) break; Transport::RingControl* control = m_producer.Control(); if (control == nullptr || Transport::Watermark::Reached(control->Progress.presentAckSerial, awaited)) { break; } DrainEventRing(m_events); } if (wait == Transport::SessionWait::TimedOut) { SessionFail(MGFatalFamily::PresentCreditTimeout, "MGPipe: Fatal{PresentCreditTimeout} - present %llu waited %u ms for " "swap %llu to come back with a credit of %u. The credit, never the " "ring's bytes, is what paces a run-ahead client (CONTRACT-P5E §2.4), " "so a credit that never returns is a server that stopped swapping", static_cast(serial), kBarrierTimeoutMs, static_cast(awaited), credit); } // ShutDown is teardown and returns: the present did not happen, the same answer // the barrier gives on a dead doorbell. if (wait == Transport::SessionWait::Reached) DrainEventRing(m_events); } m_presentsSent = serial; return serial; #else return ++m_presentsSent; #endif } // R-1's mutual-exclusion invariant, as two probes that answer honestly. // // THE CLIENT'S FLAG IS THREAD-LOCAL AND THE SERVER'S IS NOT, and the asymmetry is the // point: "am I inside a barrier wait" is a question about the calling thread, while "is the // apply thread inside the applier" is a question the GL thread asks about a DIFFERENT // thread in the shared-block control. Under role split each thread instead observes only // its own diagnostic; the client writes a different block and waits through appliedSeq. Bool ClientSession::InBarrierWait() { return g_inBarrierWait; } Bool ClientSession::ApplyThreadIsInsideApplier() { if (MG_Pipe::MGPipeRoleSplitRehearsalActive()) return g_roleInsideApplier; return g_applyThreadInsideApplier.load(std::memory_order_acquire); } void ClientSession::RefusePipeInputsTouchWhileApplierOwnsIt(const char* surface, Bool isBarrieredFill) { // THE CHAIN IS A CONJUNCTION, SO ITS ORDER IS FREE - AND IT IS ORDERED CHEAPEST-FIRST // (P5d round 3, package D). Every line below is a pure predicate whose only effect is // "no violation, go home"; the Fatal fires on the AND of all of them, so reordering // cannot change which touches abort. It changes only what a touch that does NOT abort // pays, and this guard sits on the per-verb fill path (PipeFill.cpp:542, :1951, :2789 // - MGPipeValidateForVerb is 4.11% self / 10.9% inclusive of the client thread), so // "what it costs to answer no" is the whole of its cost in a healthy process. // // The two config reads come first because they are the two answers that are constant // for the life of the process and true for nearly every process that runs this code: // a monolith build never has a role split at all, and MOBILEGL_IPC_BATCH_WAITS // defaults to 1, which retires this guard by design (see its note below). Only after // both miss do we pay for a role probe, two singleton reads, a shared atomic and the // thread_local at the end. if (MG_Config::Transport == MG_Config::TransportMode::Monolith) return; // ---- P5e (ra), CONTRACT-P5E §3.5: THE RUN-AHEAD RULE IS A DIFFERENT RULE ----------- // // It is first, before the BatchWaits return, because BatchWaits' argument does not // survive run-ahead: "the pull-verbs' own wait keeps their pulled reads fenced" is a // statement about a client that waits at every verb, and this one does not. Under // run-ahead the block is SERVER-ROLE memory (§3) and the single legal GL-thread write // is the residual fill of a BARRIERED record - the one the client is about to park // behind. Every other touch races the applier by construction, so there is no // in-applier flag to consult and no "count it" arm: the value would be torn. // // This is where a fill left standing for an unbarriered record lands, by name. if (ClientSessionInstance().RunAheadArmed()) { // ---- P5e (ra2): THE EXEMPTION IS A FACT NOW, NOT A CLAIM ------------------------ // // This arm used to read `if (isBarrieredFill) return;`, i.e. the caller's own // sentence "this touch is the residual fill of a record this thread is about to park // behind" was accepted as the whole argument. It is an argument about the FUTURE. // The order at the validate point is fill, then emit, then park, so at the instant // of the write the apply thread is still draining the unbarriered records the client // ran ahead of - and the measured consequence was the GL thread bumping // CurrentVerbSerial, withdrawing MGPipeServerClearVerbBoundary's flag and renaming // m_currentVerb underneath a record the applier was inside, which surfaced as // Fatal{UnmigratedPipeInput, "@"} on the apply thread. // Because the exemption was unconditional, this guard could not fire on any of it: // that is the answer to "does RefusePipeInputsTouchWhileApplierOwnsIt fire, and if // not, why not". // // So the sentence still exempts the fill, but only once it is TRUE of this instant. // The fill sites establish it by taking §2.5's forced wait first // (QuiesceApplierBeforeFill, PipeFill.cpp); removing that wait is the red-once and // lands here, by name, on the first barriered verb behind a run-ahead backlog. // // ApplyThreadIsInsideApplier() is an acquire load of the flag PipeApplier raises for // the whole of ApplyOne - stamp, decode and LeaveApplier included - so "not inside" // is exactly "no record is being applied" and not merely "not decoding". if (isBarrieredFill && !ApplyThreadIsInsideApplier()) return; if (Server::ServerLoop::OnApplyThread()) return; // the applier owns the block SessionFail(MGFatalFamily::RoleViolation, "MGPipe: Fatal{RoleViolation, \"gPipeInputs\"} - the GL thread touched " "gPipeInputs (%s) on a RUN-AHEAD session %s. With the client running ahead " "the block is the server's to read for as long as ANY record is in flight " "(CONTRACT-P5E §3), so the only legal GL-thread write is a barriered fill " "that has first taken §2.5's forced wait (QuiesceApplierBeforeFill)", surface, isBarrieredFill ? "in a barriered fill that did not first wait for the " "applier to catch up" : "outside a barriered fill"); } // MOBILEGL_IPC_BATCH_WAITS=1 makes "the fill runs while the apply thread applies an // earlier value record" the INTENDED shape: the fill writes only fields no record // supplies (the wire-live skips are the same answer the emitters use), the applier // writes only record-supplied fields, and the pull-verbs' own wait keeps their // pulled reads fenced. This guard exists for the model where that is not true, so // it stands only while the batch is off. if (MG_Config::Ipc.BatchWaits != 0) return; if (Server::ServerLoop::OnApplyThread()) return; // the applier owns the block inside a verb if (!ClientSessionInstance().Started()) return; // the bring-up window pre-dates the roles // MOBILEGL_IPC_VERB_BARRIER=0 is R-1's NEGATIVE CONTROL: the single-writer rule is off // by the operator's own hand there, and EmitAndWait's Fatal{BarrierViolation} owns the // red. Firing here instead would pre-empt the control's evidence line. if (!ClientSessionInstance().BarrierArmed()) return; if (!ApplyThreadIsInsideApplier()) return; // LAST, because it is the only thread_local left in the chain: an emutls call per // access in a shared library, and it must stay thread-local (see InBarrierWait's note // above - a second GL thread would otherwise read the first one's wait as its own). if (InBarrierWait()) return; SessionFail(MGFatalFamily::RoleViolation, "MGPipe: Fatal{RoleViolation, \"gPipeInputs\"} - the GL thread touched gPipeInputs " "(%s) while the apply thread was inside the applier and this thread was not in a " "barrier wait. R-1's barrier is the only thing that makes one process-wide " "gPipeInputs legal (CONTRACT-P5 table 3); a touch in this window races the " "applier's own reads of it", surface); } void ClientSession::NoteApplyThreadEnteredApplier() { g_roleInsideApplier = true; if (!MG_Pipe::MGPipeRoleSplitRehearsalActive()) g_applyThreadInsideApplier.store(true, std::memory_order_release); } void ClientSession::NoteApplyThreadLeftApplier() { g_roleInsideApplier = false; if (!MG_Pipe::MGPipeRoleSplitRehearsalActive()) g_applyThreadInsideApplier.store(false, std::memory_order_release); } Uint32 ClientSession::PumpControlPlane() { // NOT gated on m_started. The first snapshot arrives DURING Start(), before this session // is started or active - and s1's half-built teardown path depends on m_started staying // false until step 8 has succeeded, so the flag cannot be moved earlier to suit this. if (m_transport == nullptr) return 0; Uint32 adopted = 0; // Bounded rather than `while (true)`: a server that queued frames faster than this // drains them would otherwise hold the GL thread here for ever, and a frame backlog // deeper than this is a finding rather than a steady state. for (Uint32 guard = 0; guard < kMaxControlFramesPerPump; ++guard) { if (m_transport->PeekFrameSize() == 0) break; std::vector frame; if (ReceiveEnvelope(*m_transport, frame, 0) != MOBILEGL_OK) break; const ::MobileGL::Wire::CtrlEnvelope* envelope = ParseEnvelope(frame); if (envelope == nullptr) { MGLOG_E("MG_Remote client: an unverifiable control frame (%llu bytes) was dropped", static_cast(frame.size())); continue; } if (envelope->msg_type() != ::MobileGL::Wire::CtrlMsg::CapsSnapshot) { // SurfaceOp / SurfaceReply / ResyncRequest / AuxRequest / LogLine are P6's and // P7's. Named rather than ignored, so a phase that starts sending one does not // discover this loop swallowing it. MGLOG_W("MG_Remote client: control message %d is not consumed in P5", static_cast(envelope->msg_type())); continue; } if (AdoptCapsSnapshot(envelope->msg_as_CapsSnapshot())) ++adopted; } // P5e (ra, §1): the run-ahead latch rides R-12's invalidation edge, because "the first // caps adoption after Start" and "the one place a CapsSnapshot becomes a mirror // generation" are the same instant, and a latch taken anywhere else would be reading a // mirror whose generation nobody had adopted yet. Called even when `adopted` is 0: the // first call in Start() is what takes the latch, and a pump that adopted nothing still // has to leave it taken (a handshake with no snapshot latches "not armed", which is // the honest answer for a server that never said what it consumes). if (adopted != 0 || !m_runAheadLatched) LatchRunAheadFromCaps(); return adopted; } Transport::SessionProducer& ClientSession::Producer() { return m_producer; } Transport::SessionWait ClientSession::WaitForApplied(Uint64 seq, Uint32 timeoutMs) { return m_producer.WaitForApplied(seq, timeoutMs); } Bool ClientSession::ReadReply(Uint64 seq, void* outBytes, Uint64 outCapacity, Int32* outStatus, Uint64* outSize) { return m_replies.Read(seq, outBytes, outCapacity, outStatus, outSize); } Uint32 ClientSession::MaxReplyBytes() const { return m_replies.MaxReplyBytes(); } Bool ClientSession::ReplyCanHold(Uint64 bytes) const { return m_replies.CanHold(bytes); } // ID-47. Forwarded verbatim so that the message, the boundary and the abort are the pool's // and are pinned once, in SessionTest, rather than re-derived per caller. void ClientSession::RequireReadPixelsReplyFits(Uint32 width, Uint32 height, Uint32 format, Uint32 type, Uint64 bytes) const { m_replies.RequireReadPixelsFits(width, height, format, type, bytes); } Transport::EventRingConsumer& ClientSession::Events() { return m_events; } Uint32 ClientSession::DrainPublishedEvents() { // Not gated on m_started on purpose: PumpControlPlane's own pre-start call during // Start() has its ring-consumer twin here, and DrainEventRing's Valid() check is // the whole guard either case needs. return DrainEventRing(m_events); } Uint64 ClientSession::EventRingCapacityBytes() const { return m_shm.EventRingCapacity(); } Uint64 ClientSession::StageCapacityBytes() const { return m_shm.StageBytes(); } Wire::SegmentTable& ClientSession::Segments() { return m_segments; } Transport::RingControl* ClientSession::Control() { return m_shm.CmdControl(); } Transport::SessionSegments& ClientSession::Shm() { return m_shm; } Transport::ITransport* ClientSession::Control_Plane() { return m_transport; } Transport::RoleMemorySample ClientSession::SampleMemory() const { return Transport::SampleRoleMemory(Transport::MemoryRole::Client); } void ClientSession::LogMemory(const char* phase) const { Transport::LogRoleMemory(phase, SampleMemory()); } // R-10's AND R-9's numbers IN EVERY SPLIT PRIVATE LOG, not only in the lanes that set // MOBILEGL_PIPE_STATS=1. // // WHY IT IS HERE AND NOT ONLY ON THE STATS LINE. `MGPipe stats:` is an opt-in channel: two // ctest entries out of 21 set MOBILEGL_PIPE_STATS, and neither the retrace lanes nor the // 19 ordinary split entries do. R-10's proof obligation is about THE PHASE, not about the // two counting lanes - "no record on the reduced path comes near half the ring" has to be // readable from any split run that happened, which is what ID-53's per-entry private log // is for. One line per session teardown costs nothing and cannot be missed. // // IT IS ALSO WHERE THE PROOF FAILS SOFTLY. A record ABOVE the cap already aborts on the // spot with Fatal{RingOverrun} (PipeWireCodec.cpp), so this line's job is the other half: // a maximum that is merely CLOSE to the cap is not a crash and would otherwise be // invisible until the day a workload crossed it. The percentage is printed for exactly // that reason, and R-10 names the integrator as the person who decides between early // chunking and a bigger default ring when it climbs. void ClientSession::LogWireLedger() const { const Uint64 maxRecord = m_encoder.MaxRecordBytesSeen(); const Uint64 cap = m_encoder.MaxRecordBytesCap(); // Integer permille rather than a float: this file has no and a "%.1f" of a // ratio nobody can reproduce by hand is worse than two integers. const Uint64 permille = cap != 0 ? (maxRecord * 1000ull) / cap : 0ull; MGLOG_I("MG_Remote client: wire ledger: maxrec=%llu maxrecop=%s cap=%llu (%llu.%llu%% of " "RingProducer::MaxRecordBytes, half of a %llu byte SEG_CMD) cmdbytes=%llu " "ringwraps=%llu ringpads=%llu " "ringwaits=%llu emitseq=%llu cliwait=%llu clipark=%llu credit-waits=%llu - " "R-10's proof obligation, R-9's producer readings, P5d round 3's client wait " "pair and P5e's present credit, published from the session that produced them", static_cast(maxRecord), m_encoder.MaxRecordOpName(), static_cast(cap), static_cast(permille / 10), static_cast(permille % 10), static_cast(cap * 2), static_cast(m_encoder.CmdBytesWritten()), static_cast(m_encoder.CmdWraps()), static_cast(m_encoder.CmdWrapPads()), static_cast(m_encoder.StageReclaimWaits()), static_cast(m_encoder.EmitSeq()), // The same pair the summary line's wait[] carries, on the channel that exists // in EVERY split private log rather than in the two counting lanes: the round-3 // profile runs did not set MOBILEGL_PIPE_STATS either, and "did the barrier spin // or did it park" is the question their logs have to be able to answer. static_cast(m_producer.Waits()), static_cast(m_producer.Parks()), // P5e (ra, §2.4). The number the device exit reads to say whether the present // credit is what paces this workload: 0 with credit 1 means the server is // never the frame's critical path, and a count near the frame count means it // always is. It is on this line rather than only on the stats line for // LogWireLedger's own reason - a trace replay never reaches a stats summary. static_cast(m_presentCreditWaits)); } #undef MGP5_C0_STUB } // namespace MobileGL::MG_Remote::Client