[MG_Remote] (P6/dl): the Fatal-family vocabulary as a table, projected onto the wire's seven codes

CONTRACT-P6 5.2's D2 ruling, step one of the Session::Fail funnel: the internal
vocabulary before the funnel that routes through it.

a6 censused thirty family words against a seven-value wire FatalCode, 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. FatalFamilies.def is the
internal enum; the wire keeps FatalCode; each row carries the coarse code the
PEER is told when that family ends the session.

THE PROJECTION IS A TOTAL TABLE, NOT A SWITCH WITH A DEFAULT. This build has no
-Werror anywhere, so a default arm would silently swallow a family added
without a code. A row IS the arm; a family with no row is not a value of the
enum. FatalFamily.h generates the enum, the projection and the name from the
one .def, so a family, its word and its code cannot drift apart.

The buckets are documented per row in the .def: ProtocolCorruption for
malformed records / unimplemented verbs / role invariants / reply-contract
breaks; RingOverrun for a ring or event channel overrun; SegmentMismatch for a
segment too small for what a role must place in it; AbiMismatch for layout or
version disagreement; ServerCrashed for the liveness ends a WAITING peer
attributes to the other side. DeviceLost gets no row on purpose - it is armed
from the doorbell (5.3), never raised as a Fatal, and a test asserts no family
projects onto it so an abort can never masquerade as a device loss.

Five tests, all walking the table so a new row is covered without editing them:
count agreement, every family projects to a non-None code, none projects onto
DeviceLost, names distinct and non-empty, and a handful of anchors pinned by
meaning so a bulk-edit drift is caught.

No call site changes yet; the funnel that uses this is the next step.
This commit is contained in:
2026-09-21 21:33:05 -04:00
parent 5fb54f03fe
commit 3a12a48add
4 changed files with 235 additions and 0 deletions
+82
View File
@@ -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
+68
View File
@@ -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 <MG_Remote/Protocol/generated/protocol_generated.h>
#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{<name>, ...}`. 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 "<unknown>";
}
// 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
+2
View File
@@ -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)
+83
View File
@@ -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 <MG_Remote/FatalFamily.h>
#include <gtest/gtest.h>
#include <set>
#include <string>
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<std::string> seen;
for (const MGFatalFamily family : kAllFamilies) {
const std::string name = FatalFamilyName(family);
EXPECT_FALSE(name.empty());
EXPECT_NE(name, "<unknown>") << "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);
}