diff --git a/MobileGL/MG_Remote/FatalFamilies.def b/MobileGL/MG_Remote/FatalFamilies.def new file mode 100644 index 00000000..1646aa1a --- /dev/null +++ b/MobileGL/MG_Remote/FatalFamilies.def @@ -0,0 +1,82 @@ +// MobileGL - MobileGL/MG_Remote/FatalFamilies.def +// 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 + +// P6 `dl` (CONTRACT-P6 5.2, D2 ruling). THE INTERNAL FATAL VOCABULARY, and its projection onto +// the SEVEN-value wire FatalCode. +// +// a6 censused 30 distinct family words against a 7-value wire enum, so Session::Fail(FatalCode, +// detail) cannot carry the vocabulary in its first argument - the word would collapse to one of +// seven and the log would say "ProtocolCorruption" where the code said "UnmigratedVerb". So the +// family is an INTERNAL enum, generated from this file, and the wire keeps FatalCode. Each row +// carries the coarse code the PEER is told when this family kills the session; the rich word +// stays in the local log and in the SessionFault frame's `family` string. +// +// THE PROJECTION IS A TOTAL TABLE, NOT A SWITCH WITH A DEFAULT. This build has no -Werror +// anywhere (measured: CMakeLists, every *.cmake, both workflows), so a gate premised on -Wswitch +// catching a missing arm is fiction. A row here IS the arm; a family with no row does not +// compile a call that names it. +// +// WHY EACH CODE. The wire FatalCode answers one question for the peer: what KIND of end was +// this. The buckets: +// ProtocolCorruption - the peer sent, or the session reached, something that cannot be +// honoured correctly: a malformed record, an unimplemented verb/surface, +// an invariant the roles must hold, a reply that does not fit its +// contract. The peer's takeaway is "we disagree about the protocol". +// RingOverrun - a shared ring or event channel overran. A transport-capacity fact. +// SegmentMismatch - a shared SEGMENT's geometry could not hold what a role needed to put +// in it. Distinct from a ring overrun: the segment is the wrong SIZE, +// not merely full this instant. +// AbiMismatch - the two builds do not agree on layout or protocol version. +// ServerCrashed - a liveness end the WAITING side attributes to the other: a drain that +// never completed, an apply thread that is gone or never started. The +// device-lost latch is armed from the doorbell, never from these, but the +// peer that was waiting is told the server is gone. +// DeviceLost - reserved: set by the latch (5.3), never raised as a Fatal family, so no +// row projects onto it. +// +// clang-format off + +// X(Family, WireFatalCode, Why) +#define MGL_FATAL_FAMILY_LIST(X) \ + X(ProtocolCorruption, ProtocolCorruption, "a record's bounds or self-describing length was violated") \ + X(UnmigratedVerb, ProtocolCorruption, "a wire verb slot P5/P6 does not implement was reached") \ + X(UnmigratedSurface, ProtocolCorruption, "a surface op named a client-process handle that means nothing here (P12)") \ + X(UnmigratedPipeInput, ProtocolCorruption, "a PipeInputs field with no field-ownership row was read across the boundary") \ + X(RoleViolation, ProtocolCorruption, "a call ran on the wrong role/thread - an invariant the two roles must hold") \ + X(UnsetCallMask, ProtocolCorruption, "the CallMask was consulted before either half was published") \ + X(ClientTablesUninstalled, ProtocolCorruption, "the applier ran before the client installed its tables") \ + X(NoClientSession, ProtocolCorruption, "a path that needs the client session ran without one") \ + X(UnexpectedMapAccept, ProtocolCorruption, "a persistent-map accept arrived where the protocol permits none") \ + X(UncarriedInitialBytes, ProtocolCorruption, "a producer's initial bytes were required on the wire and absent") \ + X(ReplyStatusInvalid, ProtocolCorruption, "a reply carried a status outside the defined set") \ + X(ReplyError, ProtocolCorruption, "a reply-owning verb saw an error status it cannot represent") \ + X(ReplyMissing, ProtocolCorruption, "a reply slot the protocol requires was empty") \ + X(ReplyTooLarge, ProtocolCorruption, "an answer exceeded the size its verb's contract allows") \ + X(ReadbackDeclined, ProtocolCorruption, "a readback that cannot return without complete pixels was declined") \ + X(ReadbackReplyShort, ProtocolCorruption, "a readback reply carried fewer bytes than its shape requires") \ + X(UnsizedReadback, ProtocolCorruption, "a readback arrived with no declared size") \ + X(BlobMissing, ProtocolCorruption, "a record named a blob that was never staged") \ + X(BlobNotStaged, ProtocolCorruption, "a blob was referenced before its staging completed") \ + X(ArtefactsMissing, ProtocolCorruption, "a program create arrived with no artefacts") \ + X(ArchiveEmpty, ProtocolCorruption, "a create_*_state archive carried no payload") \ + X(BarrierViolation, ProtocolCorruption, "a field was pulled across a verb boundary the barrier forbids") \ + X(UnimplementedAdoptTier, ProtocolCorruption, "MOBILEGL_IPC_ADOPT_TIER named a tier P5 does not implement") \ + X(UnimplementedWritebackWait, ProtocolCorruption, "a writeback wait shape P5 does not implement was requested") \ + X(UnimplementedRemoteBackendObject, ProtocolCorruption, "a BackendObject_Remote entry point P5 does not implement was called") \ + X(UnimplementedClientSession, ProtocolCorruption, "a ClientSession entry point P5 does not implement was called") \ + X(RingOverrun, RingOverrun, "a shared ring refused a record after sufficient space had retired") \ + X(EventRingOverflow, RingOverrun, "the reverse event ring overflowed") \ + X(StageSnapshotTooNarrow, SegmentMismatch, "SEG_STAGE was too small for a snapshot a verb had to place in it") \ + X(AbiMismatch, AbiMismatch, "the two builds disagree on struct layout or protocol version") \ + X(BarrierTimeout, ServerCrashed, "appliedSeq did not reach the target within the bounded wait") \ + X(PresentCreditTimeout, ServerCrashed, "a present credit did not return within the bounded wait") \ + X(RetirementWaitFailed, ServerCrashed, "a wait for the server to retire staging did not complete") \ + X(ApplyThreadJoinTimeout, ServerCrashed, "the apply thread did not join within its bound at teardown") \ + X(ApplyThreadNotRunning, ServerCrashed, "an operation needed the apply thread and it was not running") + +// clang-format on diff --git a/MobileGL/MG_Remote/FatalFamily.h b/MobileGL/MG_Remote/FatalFamily.h new file mode 100644 index 00000000..4c9b6e0d --- /dev/null +++ b/MobileGL/MG_Remote/FatalFamily.h @@ -0,0 +1,68 @@ +// MobileGL - MobileGL/MG_Remote/FatalFamily.h +// 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 + +// P6 `dl` (CONTRACT-P6 5.2). The internal Fatal vocabulary and its total projection onto the +// wire FatalCode, both generated from FatalFamilies.def so a family, its name and its wire code +// cannot drift apart: adding a family is one row, and a row that forgets its code does not +// compile. + +#pragma once + +#include + +#include "FatalFamilies.def" + +namespace MobileGL::MG_Remote { + + // One value per row of FatalFamilies.def, in file order. The names ARE the family words that + // appear in every `Fatal{...}` log line, which is what lets FatalFamilyName round-trip + // against the string a site passes. + enum class MGFatalFamily : ::std::uint32_t { +#define X(Family, WireCode, Why) Family, + MGL_FATAL_FAMILY_LIST(X) +#undef X + }; + + // THE PROJECTION, AS A TOTAL TABLE. Not a switch with a default arm: this build has no + // -Werror, so a default would silently swallow a family added without a code. Every family + // that exists has a row, and a family that does not exist is not a value of the enum. + inline ::MobileGL::Wire::FatalCode FatalCodeForFamily(MGFatalFamily family) { + switch (family) { +#define X(Family, WireCode, Why) \ + case MGFatalFamily::Family: return ::MobileGL::Wire::FatalCode::WireCode; + MGL_FATAL_FAMILY_LIST(X) +#undef X + } + // Unreachable for any real enum value; kept so the function is total for the compiler + // without a `default:` that would mask a missing row. + return ::MobileGL::Wire::FatalCode::ProtocolCorruption; + } + + // The family's own word, byte-for-byte what appears inside `Fatal{, ...}`. A death + // site passes both the enum and its full message string; a test asserts this name is present + // in that string, which is what keeps the two from diverging. + inline const char* FatalFamilyName(MGFatalFamily family) { + switch (family) { +#define X(Family, WireCode, Why) \ + case MGFatalFamily::Family: return #Family; + MGL_FATAL_FAMILY_LIST(X) +#undef X + } + return ""; + } + + // The count, for a test that walks every family. + inline constexpr ::std::size_t MGFatalFamilyCount() { + ::std::size_t n = 0; +#define X(Family, WireCode, Why) ++n; + MGL_FATAL_FAMILY_LIST(X) +#undef X + return n; + } + +} // namespace MobileGL::MG_Remote diff --git a/MobileGL/MG_Test/Wire/CMakeLists.txt b/MobileGL/MG_Test/Wire/CMakeLists.txt index f28a9077..db5cc562 100644 --- a/MobileGL/MG_Test/Wire/CMakeLists.txt +++ b/MobileGL/MG_Test/Wire/CMakeLists.txt @@ -12,6 +12,8 @@ set(MOBILEGL_WIRE_TESTS # P5 s1: the ring-owning session pair - four ShmSegment-backed segments, the five # watermarks with real writers, the reply slot pool and the event ring. SessionTest + # P6 dl: the Fatal-family table and its total projection onto the wire FatalCode. + FatalFamilyTest ) if (NOT WIN32) diff --git a/MobileGL/MG_Test/Wire/FatalFamilyTest.cpp b/MobileGL/MG_Test/Wire/FatalFamilyTest.cpp new file mode 100644 index 00000000..623a21a0 --- /dev/null +++ b/MobileGL/MG_Test/Wire/FatalFamilyTest.cpp @@ -0,0 +1,83 @@ +// MobileGL - MobileGL/MG_Test/Wire/FatalFamilyTest.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 + +// P6 `dl` (CONTRACT-P6 5.2). The Fatal-family table and its projection onto the wire FatalCode. + +#include + +#include + +#include +#include + +using namespace MobileGL::MG_Remote; +using MobileGL::Wire::FatalCode; + +namespace { + + // Every family, walked once, so a new row is covered without editing this list. + const MGFatalFamily kAllFamilies[] = { +#define X(Family, WireCode, Why) MGFatalFamily::Family, + MGL_FATAL_FAMILY_LIST(X) +#undef X + }; + +} // namespace + +TEST(FatalFamily, TheTableAndTheEnumAgreeOnCount) { + EXPECT_EQ(sizeof(kAllFamilies) / sizeof(kAllFamilies[0]), MGFatalFamilyCount()); + // a6 censused thirty; the vocabulary has grown since, on purpose. The point of this line is + // that the number is stated, not that it is any particular value - a change to it is a + // deliberate edit here, which is exactly the "grows on purpose" the census gate also asks. + EXPECT_GE(MGFatalFamilyCount(), 30u); +} + +TEST(FatalFamily, EveryFamilyProjectsToADefinedWireCode) { + // A total table: every family maps, and never to None (which is "no fatal"). None as a + // projection would say a death was not a death. + for (const MGFatalFamily family : kAllFamilies) { + const FatalCode code = FatalCodeForFamily(family); + EXPECT_NE(code, FatalCode::None) + << FatalFamilyName(family) << " projects onto FatalCode::None"; + } +} + +TEST(FatalFamily, NoFamilyProjectsOntoDeviceLost) { + // 5.3: DeviceLost is armed from the doorbell's hangup, never raised as a Fatal. A family that + // projected onto it would let an abort masquerade as a device loss, which is the exact + // confusion the latch/abort split exists to prevent. + for (const MGFatalFamily family : kAllFamilies) { + EXPECT_NE(FatalCodeForFamily(family), FatalCode::DeviceLost) + << FatalFamilyName(family) << " projects onto DeviceLost, which only the latch may set"; + } +} + +TEST(FatalFamily, NamesAreDistinctAndNonEmpty) { + std::set seen; + for (const MGFatalFamily family : kAllFamilies) { + const std::string name = FatalFamilyName(family); + EXPECT_FALSE(name.empty()); + EXPECT_NE(name, "") << "a real family resolved to the fallback name"; + EXPECT_TRUE(seen.insert(name).second) << name << " is not a distinct family name"; + } +} + +TEST(FatalFamily, TheAnchorFamiliesProjectWhereTheirMeaningSays) { + // A handful pinned by hand, so a projection that drifted in a bulk edit is caught by meaning + // and not only by "it still maps to something". + EXPECT_EQ(FatalCodeForFamily(MGFatalFamily::ProtocolCorruption), FatalCode::ProtocolCorruption); + EXPECT_EQ(FatalCodeForFamily(MGFatalFamily::RingOverrun), FatalCode::RingOverrun); + EXPECT_EQ(FatalCodeForFamily(MGFatalFamily::EventRingOverflow), FatalCode::RingOverrun); + EXPECT_EQ(FatalCodeForFamily(MGFatalFamily::AbiMismatch), FatalCode::AbiMismatch); + EXPECT_EQ(FatalCodeForFamily(MGFatalFamily::StageSnapshotTooNarrow), FatalCode::SegmentMismatch); + // The liveness families are what a WAITING peer attributes to the other side being gone. + EXPECT_EQ(FatalCodeForFamily(MGFatalFamily::BarrierTimeout), FatalCode::ServerCrashed); + EXPECT_EQ(FatalCodeForFamily(MGFatalFamily::ApplyThreadNotRunning), FatalCode::ServerCrashed); + // An unmigrated verb is a protocol-level "cannot honour this", not a crash. + EXPECT_EQ(FatalCodeForFamily(MGFatalFamily::UnmigratedVerb), FatalCode::ProtocolCorruption); +}