diff --git a/MobileGL/MG_Pipe/PipeApply.cpp b/MobileGL/MG_Pipe/PipeApply.cpp index 1b294ef7..726fcad7 100644 --- a/MobileGL/MG_Pipe/PipeApply.cpp +++ b/MobileGL/MG_Pipe/PipeApply.cpp @@ -1667,10 +1667,22 @@ namespace MobileGL::MG_Pipe { void MGPipeApplyCreateRenderState(const MGPRenderStateDesc& desc, const void* chunkBytes) { MOBILEGL_ASSERT(desc.Cso.Slot >= kMGPipeFirstAllocatableSlot, "create_render_state named the reserved slot 0"); - if (desc.Cso.Slot >= g_applier.RenderStateCsos.size()) { - g_applier.RenderStateCsos.resize(desc.Cso.Slot + 1); + MGPipeRenderStateCsoRecord* recordAt = + RecordAt(g_applier.RenderStateCsos, desc.Cso.Slot, kMGPipeMaxRenderStateCsoSlots); + if (recordAt == nullptr) { +#if MOBILEGL_BUILD_DISAGGREGATED + MGPipeSessionFail(MGPipeFatalFamily::ProtocolCorruption, + "MGPipe: Fatal{ProtocolCorruption, \"CreateRenderState.Cso.Slot\"} - " + "create_render_state {slot=%u, gen=%u} is outside the record table bound (%u)", + desc.Cso.Slot, desc.Cso.Gen, kMGPipeMaxRenderStateCsoSlots); +#else + MGP_TRIP_WIRE_REPORT("MGPipe: " MGP_TRIP_WIRE_TAG("ProtocolCorruption") + " create_render_state {slot=%u, gen=%u} is outside the record table bound (%u)", + desc.Cso.Slot, desc.Cso.Gen, kMGPipeMaxRenderStateCsoSlots); +#endif + return; } - MGPipeRenderStateCsoRecord& record = g_applier.RenderStateCsos[desc.Cso.Slot]; + MGPipeRenderStateCsoRecord& record = *recordAt; // NEITHER BRANCH MAY LEAVE ITS BAD CASE TO MOBILEGL_ASSERT. The slot the client is // naming may be a RECYCLED one whose record still holds the previous occupant's 396 diff --git a/MobileGL/MG_Pipe/PipeApply.h b/MobileGL/MG_Pipe/PipeApply.h index 79b0cf4d..9fc13ff8 100644 --- a/MobileGL/MG_Pipe/PipeApply.h +++ b/MobileGL/MG_Pipe/PipeApply.h @@ -162,6 +162,9 @@ namespace MobileGL::MG_Pipe { // bounded by kMGPipeMaxResourceSlots and each grows only to its own dense high-water mark. inline constexpr Uint32 kMGPipeMaxResourceSlots = 1u << 20; inline constexpr Uint32 kMGPipeMaxVertexElementsSlots = 1u << 16; + // create_render_state records retain one 396-byte pipeline half each. Keep their + // slot-indexed table bounded independently so a peer cannot request a huge resize. + inline constexpr Uint32 kMGPipeMaxRenderStateCsoSlots = 1u << 16; // P4a's three, and the argument is written out for each because the records differ in // size. None is ever allocated by being named: the tables grow to the client's own dense // high-water mark and no further, so the bound costs nothing until a record is corrupt. diff --git a/MobileGL/MG_Pipe/PipeSessionFail.h b/MobileGL/MG_Pipe/PipeSessionFail.h index a50beab6..fbd18d68 100644 --- a/MobileGL/MG_Pipe/PipeSessionFail.h +++ b/MobileGL/MG_Pipe/PipeSessionFail.h @@ -54,6 +54,10 @@ namespace MobileGL::MG_Pipe { UnmigratedVerb, // A call that reached an arm this build did not compile, or ran on the wrong role. RoleViolation, +#if MOBILEGL_BUILD_DISAGGREGATED + // A malformed peer record rejected by the server-side applier. + ProtocolCorruption, +#endif }; // What the layer above installs. `line` is the FULLY FORMATTED message, so the hook forwards diff --git a/MobileGL/MG_Remote/FatalFunnel.cpp b/MobileGL/MG_Remote/FatalFunnel.cpp index e6b1eefb..39b37134 100644 --- a/MobileGL/MG_Remote/FatalFunnel.cpp +++ b/MobileGL/MG_Remote/FatalFunnel.cpp @@ -53,6 +53,11 @@ namespace MobileGL::MG_Remote { case MG_Pipe::MGPipeFatalFamily::RoleViolation: mapped = MGFatalFamily::RoleViolation; break; +#if MOBILEGL_BUILD_DISAGGREGATED + case MG_Pipe::MGPipeFatalFamily::ProtocolCorruption: + mapped = MGFatalFamily::ProtocolCorruption; + break; +#endif } SessionFail(mapped, "%s", line); } diff --git a/MobileGL/MG_Test/Wire/ServerSpawnTest.cpp b/MobileGL/MG_Test/Wire/ServerSpawnTest.cpp index 7fcefb05..38771e04 100644 --- a/MobileGL/MG_Test/Wire/ServerSpawnTest.cpp +++ b/MobileGL/MG_Test/Wire/ServerSpawnTest.cpp @@ -39,10 +39,20 @@ #include #include #include +#include +#include +#include +#include +#include +#include #include #include +#include +#include +#include +#include #include #include #include @@ -68,6 +78,56 @@ namespace { return std::string("/tmp/mgl-sm-") + label + "-" + std::to_string(::getpid()) + ".sock"; } + struct ScopedEnvironment { + std::string name; + std::string oldValue; + bool hadOldValue = false; + ScopedEnvironment(const char* key, const std::string& value) : name(key) { + if (const char* prior = std::getenv(key)) { + oldValue = prior; + hadOldValue = true; + } + ::setenv(key, value.c_str(), 1); + } + ~ScopedEnvironment() { + if (hadOldValue) ::setenv(name.c_str(), oldValue.c_str(), 1); + else ::unsetenv(name.c_str()); + } + }; + + // A peer can mutate the command-ring bytes AFTER the production encoder has + // accepted and written them. The sequence, framing size, and submitted + // watermark stay valid; only the opcode becomes unknown to the server. + // This is the seam the later D11/PH-2 controls will use for payload fields. + template + bool EncodeThenMutateHeaderAndPublish(Client::ClientSession& session, MobileGL::MG_Pipe::MGPWireOp op, + const Payload& payload, Mutator&& mutate) { + auto* link = session.DataLink(); + if (link == nullptr || !link->Attached()) return false; + Wire::WireRecordLayout layout{}; + if (!Wire::MGPipeWireRecordLayout(op, &payload, layout) || + layout.PayloadBytes != sizeof(payload)) return false; + auto& encoder = session.Encoder(); + const std::uint64_t seq = encoder.EncodeRecord(op, &payload, sizeof(payload)); + if (seq == Wire::kInvalidSeq) return false; + auto& commands = link->CommandsOut(); + const auto* arena = link->RecordArena(); + const std::uint64_t head = commands.LocalHead(); + if (arena == nullptr || arena->Base == nullptr || arena->Capacity == 0 || + head < layout.TotalBytes) return false; + const std::uint64_t start = head - layout.TotalBytes; + auto* bytes = static_cast(arena->Base) + (start & arena->Mask); + auto* header = reinterpret_cast(bytes); + if (header->kind != static_cast(op) || header->size != layout.TotalBytes) + return false; + mutate(*header, bytes + sizeof(Transport::RingRecordHeader)); + // Use the real session publisher after mutation so both the ring head + // and the producer-owned submittedSeq stay monotone and teardown knows + // the record's sequence. Only the record bytes bypass encoder checks. + session.Producer().PublishAndNotify(seq); + return link->Flush() == MOBILEGL_OK; + } + std::string ServerImage() { if (const char* explicitPath = std::getenv("MOBILEGL_TEST_SERVER_PATH")) { return explicitPath; @@ -80,6 +140,22 @@ namespace { std::unique_ptr client; }; + // Keep the negative-path case leak-free even when an ASSERT returns early. + struct ScopedSessionCleanup { + Session& session; + ~ScopedSessionCleanup() { + Client::ClientSessionInstance().Stop(); + if (session.client) session.client->Shutdown(); + if (session.server.pid > 0) { + int ignored = -1; + if (Server::ReapServer(session.server, 50, &ignored) != MOBILEGL_OK) { + ::kill(session.server.pid, SIGKILL); + (void)Server::ReapServer(session.server, 5000, &ignored); + } + } + } + }; + // The two halves a real deployment does separately: start a process, then // connect to it. The bounded connect retry is the CLIENT's, not the // launcher's - a launcher that waited for the server to be ready would be @@ -129,6 +205,88 @@ namespace { } // namespace +TEST(ServerSpawnTest, ARawPeerMutatesARecordAfterTheClientEncoderAcceptedIt) { + const std::string endpoint = Endpoint("raw-peer"); + const std::string logBase = endpoint + ".log"; + ScopedEnvironment logPath("MOBILEGL_LOG_FILE_PATH", logBase); + Session session; + ScopedSessionCleanup cleanup{session}; + ASSERT_TRUE(Bring("raw-peer", &session)); + ASSERT_EQ(Handshake(session), MOBILEGL_OK); + + MobileGL::MG_Pipe::MGPMemoryBarrier barrier{}; + barrier.Bits = 0x2000u; + barrier.ByRegion = 0; + auto& client = Client::ClientSessionInstance(); + auto* link = client.DataLink(); + ASSERT_NE(link, nullptr); + ASSERT_TRUE(EncodeThenMutateHeaderAndPublish( + client, MobileGL::MG_Pipe::MGPWireOp::MemoryBarrier, barrier, + [](Transport::RingRecordHeader& header, std::uint8_t*) { header.kind = 0xFFFFu; })) + << "the test peer failed to mutate and publish the accepted record bytes"; + + int exitCode = -1; + const auto reaped = Server::ReapServer(session.server, 5000, &exitCode); + if (reaped != MOBILEGL_OK) { + client.Stop(); + if (session.server.pid > 0) { + ::kill(session.server.pid, SIGKILL); + (void)Server::ReapServer(session.server, 5000, &exitCode); + } + } + ASSERT_EQ(reaped, MOBILEGL_OK) << "server did not reject the mutated record promptly"; + EXPECT_EQ(exitCode, -SIGABRT) << "the malformed opcode did not reach the server decoder"; + const std::string log = MobileGL::MG_Util::Debug::ReadRoleLogs(logBase.c_str()); + EXPECT_NE(log.find("protocol corruption applying"), std::string::npos) + << "the server process exited without the protocol corruption diagnostic"; + EXPECT_NE(log.find("unknown opcode"), std::string::npos) + << "the malformed opcode was not identified in the server log"; + client.Stop(); +} + +TEST(ServerSpawnTest, RawPeerOutOfRangeRenderStateCsoSlotGetsNamedProtocolCorruption) { + const std::string endpoint = Endpoint("bad-renderstate-slot"); + const std::string logBase = endpoint + ".log"; + ScopedEnvironment logPath("MOBILEGL_LOG_FILE_PATH", logBase); + Session session; + ScopedSessionCleanup cleanup{session}; + ASSERT_TRUE(Bring("bad-renderstate-slot", &session)); + ASSERT_EQ(Handshake(session), MOBILEGL_OK); + + auto& client = Client::ClientSessionInstance(); + MobileGL::MG_Pipe::MGPRenderStateDesc desc{}; + desc.Cso = {7u, 1u}; + desc.ChunkMask = MobileGL::MG_Pipe::MGPipeRenderStateChunkDetail::kAllPipelineHalfBits; + const auto chunkBytes = MobileGL::MG_Pipe::MGPipePipelineChunkBlobBytes(desc.ChunkMask); + std::vector chunks(chunkBytes, 0x5A); + desc.Blob = client.Encoder().StageBytes(chunks.data(), chunks.size()); + const MobileGL::Uint32 encoderAcceptedSlot = desc.Cso.Slot; + ASSERT_TRUE(EncodeThenMutateHeaderAndPublish( + client, MobileGL::MG_Pipe::MGPWireOp::CreateRenderState, desc, + [](Transport::RingRecordHeader&, std::uint8_t* payloadBytes) { + auto* wireDesc = reinterpret_cast(payloadBytes); + wireDesc->Cso.Slot = MobileGL::MG_Pipe::kMGPipeMaxRenderStateCsoSlots; + })) << "the test peer failed to mutate and publish the encoder-accepted record bytes"; + EXPECT_LT(encoderAcceptedSlot, MobileGL::MG_Pipe::kMGPipeMaxRenderStateCsoSlots); + + int exitCode = -1; + const auto reaped = Server::ReapServer(session.server, 5000, &exitCode); + if (reaped != MOBILEGL_OK) { + client.Stop(); + if (session.server.pid > 0) { + ::kill(session.server.pid, SIGKILL); + (void)Server::ReapServer(session.server, 5000, &exitCode); + } + } + ASSERT_EQ(reaped, MOBILEGL_OK) << "server did not reject the mutated record promptly"; + EXPECT_EQ(exitCode, -SIGABRT) << "the malformed CSO slot did not reach the server applier"; + const std::string log = MobileGL::MG_Util::Debug::ReadRoleLogs(logBase.c_str()); + EXPECT_NE(log.find("Fatal{ProtocolCorruption, \"CreateRenderState.Cso.Slot\"}"), + std::string::npos) + << "the server process exited without the named protocol corruption diagnostic"; + client.Stop(); +} + TEST(ServerSpawnTest, StartsAServerProcessAndHandshakesAcrossIt) { const int before = Server::CountOwnChildren(); ASSERT_GE(before, 0) << "/proc unreadable; the process-tree gate cannot run here";