diff --git a/MobileGL/MG_Backend/MGPipe/PipeInputs.cpp b/MobileGL/MG_Backend/MGPipe/PipeInputs.cpp index 0a9b5773..583479c0 100644 --- a/MobileGL/MG_Backend/MGPipe/PipeInputs.cpp +++ b/MobileGL/MG_Backend/MGPipe/PipeInputs.cpp @@ -7,12 +7,13 @@ // End of Source File Header // The backend-side half of the PipeInputs block: the poison Fatal with its verb name, the -// name lookups the runtime knobs need, and - in a verify build - the per-field equality and -// the corruption injector the comparator uses. Compiled only under MOBILEGL_PIPE_PUSH +// name lookups the runtime knobs need, and - in a verify build - the per-field equality, +// the entry comparator and the corruption injector. Compiled only under MOBILEGL_PIPE_PUSH // (CMakeLists.txt appends it to SOURCE_FILES there), so the pull build never sees it. Spells // no MG_State global: everything that reads the live context lives in MG_Impl/Pipe/PipeFill.cpp. #include +#include #include namespace MobileGL::MG_Pipe { @@ -43,6 +44,8 @@ namespace MobileGL::MG_Pipe { #if MOBILEGL_PIPE_VERIFY namespace { + using CurrentVertexAttributeValue = PipeInputs::CurrentVertexAttributeValue; + // Every overload is declared up front: the array overloads recurse into their element // type, and a call inside a template only sees what was declared before the template. template @@ -54,6 +57,7 @@ namespace MobileGL::MG_Pipe { template Bool StorageEqual(const T (&a)[N], const T (&b)[N]); Bool StorageEqual(const PipeInputs::IndexedCapabilities& a, const PipeInputs::IndexedCapabilities& b); + Bool StorageEqual(const CurrentVertexAttributeValue& a, const CurrentVertexAttributeValue& b); template void CorruptStorage(T& v); template @@ -63,6 +67,7 @@ namespace MobileGL::MG_Pipe { template void CorruptStorage(T (&a)[N]); void CorruptStorage(PipeInputs::IndexedCapabilities& c); + void CorruptStorage(CurrentVertexAttributeValue& v); // ---- equality over one field's storage ---- // O-class storage compares by identity: a raw pointer into the context, or the object a @@ -86,6 +91,14 @@ namespace MobileGL::MG_Pipe { Bool StorageEqual(const PipeInputs::IndexedCapabilities& a, const PipeInputs::IndexedCapabilities& b) { return StorageEqual(a.Blend, b.Blend) && StorageEqual(a.ScissorTest, b.ScissorTest); } + // Three scalar arrays and nothing else (Core.h), so a bitwise compare has no padding to + // false-differ on and keeps a NaN float attribute equal to itself. The size assertion is + // what turns a fourth member into a build break rather than a blind spot. + Bool StorageEqual(const CurrentVertexAttributeValue& a, const CurrentVertexAttributeValue& b) { + static_assert(sizeof(CurrentVertexAttributeValue) == 3 * 4 * 4, + "CurrentVertexAttributeValue grew a member; update the comparator"); + return std::memcmp(&a, &b, sizeof(CurrentVertexAttributeValue)) == 0; + } template Bool StorageEqual(const T& a, const T& b) { return MGPipeFieldEqual(a, b); @@ -93,15 +106,21 @@ namespace MobileGL::MG_Pipe { // ---- corruption of one field's storage ---- // Every shape is perturbed in a way the comparator above must see: a Bool flips, a - // scalar or enum moves by one, a pointer becomes null, a SharedPtr is dropped, an array - // corrupts its first element, and any other struct has its first byte XOR'ed with 0x5A. + // scalar or enum moves by one, a pointer's low bits are flipped (never dereferenced: + // the snapshot is only ever compared), a SharedPtr becomes an aliasing pointer to a + // flipped address with no control block, an array corrupts its first element, and any + // other struct has its first byte XOR'ed with 0x5A. + template + T* FlipPointer(T* p) { + return reinterpret_cast(reinterpret_cast(p) ^ 0x5A); + } template void CorruptStorage(T*& p) { - p = nullptr; + p = FlipPointer(p); } template void CorruptStorage(SharedPtr& p) { - p.reset(); + p = SharedPtr(SharedPtr(), FlipPointer(p.get())); } template void CorruptStorage(T (&a)[N]) { @@ -110,6 +129,9 @@ namespace MobileGL::MG_Pipe { void CorruptStorage(PipeInputs::IndexedCapabilities& c) { CorruptStorage(c.Blend); } + void CorruptStorage(CurrentVertexAttributeValue& v) { + v.floatValue[0] += 1.f; + } template void CorruptStorage(T& v) { if constexpr (std::is_same_v) { @@ -128,13 +150,25 @@ namespace MobileGL::MG_Pipe { } } // namespace - Bool MGPipeInputsFieldEqual(MGPipeInputField field, PipeInputs& a, PipeInputs& b) { + Bool MGPipeInputsFieldEqual(MGPipeInputField field, const PipeInputs& a, const PipeInputs& b) { // A forwarded field has no storage and is equal by definition; VisitStorage answers // false for it, hence the explicit sticky test first. if (kMGPipeInputFieldSticky[static_cast(field)]) return true; return PipeInputs::VisitStorage(field, a, b, [](const auto& x, const auto& y) { return StorageEqual(x, y); }); } + Bool MGPipeVerifyInputs(const PipeInputs& pushed, const PipeInputs& snapshot, const MGPipeFieldMask& mask, + MGPipeInputField* outField) { + for (SizeT i = 0; i < kMGPipeInputFieldCount; ++i) { + const auto field = static_cast(i); + if (!MGPipeFieldMaskHas(mask, field)) continue; + if (MGPipeInputsFieldEqual(field, pushed, snapshot)) continue; + if (outField != nullptr) *outField = field; + return false; + } + return true; + } + Bool MGPipeApplyVerifyCorruption(PipeInputs& snapshot, MGPipeInputField field) { return PipeInputs::VisitStorage(field, snapshot, snapshot, [](auto& x, auto&) { CorruptStorage(x); diff --git a/MobileGL/MG_Backend/MGPipe/PipeInputs.h b/MobileGL/MG_Backend/MGPipe/PipeInputs.h index 67b048c5..3b51f7d2 100644 --- a/MobileGL/MG_Backend/MGPipe/PipeInputs.h +++ b/MobileGL/MG_Backend/MGPipe/PipeInputs.h @@ -50,10 +50,20 @@ namespace MobileGL::MG_Pipe { #else #define MGP_INPUT_CHECK(Field) ((void)0) #endif - // The compare-at-read hook of the MOBILEGL_PIPE_VERIFY comparator (P1 brief D8): re-reads - // the same accessor with the same indices from the live context and compares. Armed by - // the comparator commit; until then every build's accessor is a load. + // The compare-at-read hook of the MOBILEGL_PIPE_VERIFY comparator (P1 brief D8), defined + // in MG_Impl/Pipe/PipeFill.cpp: re-reads the field from the live context and compares it + // against the stored value, and reports the FIRST divergence as + // Fatal{PipeVerifyDiffer, "Field@Verb", verb=, where=read} (the indices go in a + // preceding MGLOG_E). Only the live block (gPipeInputs) is verified; a snapshot's own + // accessors are plain loads. Off in every other build. + struct PipeInputs; +#if MOBILEGL_PIPE_VERIFY + void MGPipeVerifyReadHook(const PipeInputs& self, MGPipeInputField field, Uint index0, Uint index1); +#define MGP_INPUT_VERIFY_READ(Field, Index0, Index1) \ + ::MobileGL::MG_Pipe::MGPipeVerifyReadHook(*this, (Field), static_cast(Index0), static_cast(Index1)) +#else #define MGP_INPUT_VERIFY_READ(Field, Index0, Index1) ((void)0) +#endif // The V/O storage of every field that has storage, by field id. The seven F-class // (forwarded) fields have none. PipeInputs::VisitStorage dispatches on this list, which @@ -675,10 +685,20 @@ namespace MobileGL::MG_Pipe { // PipeInputs.cpp. Per-field equality for the entry compare (P1 brief D8): V by value // through G4's MGPipeFieldEqual (bitwise floats, field-wise structs), O by identity, F // always equal (no storage). - Bool MGPipeInputsFieldEqual(MGPipeInputField field, PipeInputs& a, PipeInputs& b); + Bool MGPipeInputsFieldEqual(MGPipeInputField field, const PipeInputs& a, const PipeInputs& b); + // PipeInputs.cpp. The entry compare: every field in `mask` of the pushed block against the + // snapshot, first differing field out. Exported from the shared library on purpose - the + // retrace-verify CI job proves it swapped in a verify build by finding this symbol with + // nm -D, so a "green" run against a library without the comparator cannot happen. +#if defined(__GNUC__) || defined(__clang__) + __attribute__((visibility("default"))) +#endif + Bool MGPipeVerifyInputs(const PipeInputs& pushed, const PipeInputs& snapshot, const MGPipeFieldMask& mask, + MGPipeInputField* outField); // PipeInputs.cpp. Negative control A: perturbs one field's storage (flip a Bool, +1 a - // scalar, ^0x5A the first byte of a struct, null a pointer). Returns false for a forwarded - // field, which has nothing to corrupt. + // scalar, ^0x5A the first byte of a struct, flip a pointer's low bits - never + // dereferenced, the snapshot is only ever compared). Returns false for a forwarded field, + // which has nothing to corrupt. Bool MGPipeApplyVerifyCorruption(PipeInputs& snapshot, MGPipeInputField field); #endif } // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp index 750239f8..33891ec1 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.cpp +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -318,8 +318,114 @@ namespace MobileGL::MG_Pipe { [[maybe_unused]] Bool IsOmitted(MGPipeVerb verb, MGPipeInputField field) { return g_omission.Armed && g_omission.Verb == verb && g_omission.Field == field; } + +#if MOBILEGL_PIPE_VERIFY + // ---- the MOBILEGL_PIPE_VERIFY comparator (P1 brief D8) ---- + // Two mechanisms, both active only when Features.PipeVerify is set: the ENTRY compare + // once per verb (the pushed block against a second snapshot of the live context, + // taken at the same instant - tautological until P2 gives the first arm a real + // filler, and kept falsifiable by MOBILEGL_PIPE_VERIFY_CORRUPT), and the + // COMPARE-AT-READ in every accessor (the stored value against a fresh read of the + // live context at the moment the backend reads it - the arm that is real in P1: it + // catches a value that changed between the verb boundary and the read). + PipeInputs g_snapshot{}; // the second arm + PipeInputs g_readScratch{}; // where the compare-at-read re-read lands + + struct VerifyState { + Bool Parsed = false; + Bool Enabled = false; + Bool Fatal = true; + Bool InHook = false; // a re-read that re-enters an accessor is not re-verified + Optional Corrupt; + std::atomic Divergences{0}; + ~VerifyState() { + const Uint64 count = Divergences.load(std::memory_order_relaxed); + if (count != 0) { + MGLOG_E("MGPipe: verify summary - %llu divergence(s) survived MOBILEGL_PIPE_VERIFY_FATAL=0", + static_cast(count)); + } + } + }; + VerifyState g_verify; + + void ArmVerify() { + if (g_verify.Parsed) return; + g_verify.Parsed = true; + g_verify.Enabled = MG_Config::Features.PipeVerify; + if (!g_verify.Enabled) return; + g_verify.Fatal = MG_Config::Features.PipeVerifyFatal; + const String& corrupt = MG_Config::Features.PipeVerifyCorrupt; + if (!corrupt.empty()) { + const auto field = MGPipeFindInputField(corrupt.c_str()); + if (!field) { + BadKnob("MOBILEGL_PIPE_VERIFY_CORRUPT", corrupt.c_str(), "no such field in kMGPipeInputFieldNames"); + } + g_verify.Corrupt = field; + } + // The lanes grep for this line: a verify run whose log lacks it never armed. + MGLOG_I("MGPipe: verify armed - %u fields, %u verbs, fatal=%d", static_cast(kMGPipeInputFieldCount), + static_cast(kMGPipeVerbCount), g_verify.Fatal ? 1 : 0); + if (g_verify.Corrupt) { + MGLOG_I("MGPipe: verify corruption armed - %s", kMGPipeInputFieldNames[static_cast(*g_verify.Corrupt)]); + } + } + + void ReportDivergence(MGPipeInputField field, const char* where) { + const Uint64 serial = MGPipeFillAccess::Filled(gPipeInputs).CurrentVerbSerial; + MGLOG_F("MGPipe: Fatal{PipeVerifyDiffer, \"%s@%s\", verb=%llu, where=%s}", + kMGPipeInputFieldNames[static_cast(field)], MGPipeVerbName(gPipeInputs.CurrentVerb()), + static_cast(serial), where); + if (g_verify.Fatal) std::abort(); + g_verify.Divergences.fetch_add(1, std::memory_order_relaxed); + } + + void EntryCompare(PipeInputs& inputs, const MGPipeFieldMask& mask) { + if (!g_verify.Enabled) return; + SnapshotFromGLContext(g_snapshot, mask); + // Negative control A: perturb the SNAPSHOT arm, so a green run goes red naming the + // field. A field outside this verb's mask is not compared and stays untouched. + if (g_verify.Corrupt && MGPipeFieldMaskHas(mask, *g_verify.Corrupt)) { + MGPipeApplyVerifyCorruption(g_snapshot, *g_verify.Corrupt); + } + MGPipeInputField differing = MGPipeInputField::kFieldCount; + if (!MGPipeVerifyInputs(inputs, g_snapshot, mask, &differing)) ReportDivergence(differing, "entry"); + } +#endif // MOBILEGL_PIPE_VERIFY } // namespace +#if MOBILEGL_PIPE_VERIFY + void SnapshotFromGLContext(PipeInputs& snapshot, const MGPipeFieldMask& mask) { + auto* ctx = LiveContext(); + MGPipeFillAccess::SetIdentity(snapshot, ctx); + MGPipeFillAccess::SetVerb(snapshot, gPipeInputs.CurrentVerb()); + if (ctx == nullptr) return; + for (SizeT i = 0; i < kMGPipeInputFieldCount; ++i) { + const auto field = static_cast(i); + if (!MGPipeFieldMaskHas(mask, field) || kMGPipeInputFieldSticky[i]) continue; + MGPipeFillAccess::CopyField(snapshot, *ctx, field); + } + } + + void MGPipeVerifyReadHook(const PipeInputs& self, MGPipeInputField field, Uint index0, Uint index1) { + if (&self != &gPipeInputs || !g_verify.Enabled || g_verify.InHook) return; + const auto index = static_cast(field); + if (kMGPipeInputFieldSticky[index]) return; + auto* ctx = LiveContext(); + if (ctx == nullptr) return; + // The whole field is re-read and compared - a superset of "the same indices", so a + // divergence in an index the backend did not ask for is still a divergence between + // the boundary value and the live value. The indices only decorate the report. + g_verify.InHook = true; + MGPipeFillAccess::CopyField(g_readScratch, *ctx, field); + const Bool equal = MGPipeInputsFieldEqual(field, self, g_readScratch); + g_verify.InHook = false; + if (equal) return; + MGLOG_E("MGPipe: verify read of %s (index %u, %u) differs from the live context", kMGPipeInputFieldNames[index], + index0, index1); + ReportDivergence(field, "read"); + } +#endif // MOBILEGL_PIPE_VERIFY + void MGPipeSetPoisonOmission(const char* verb, const char* field) { if (verb == nullptr || field == nullptr) { g_omission = PoisonOmission{}; @@ -386,6 +492,16 @@ namespace MobileGL::MG_Pipe { void MGPipeFillForVerb(MGPipeVerb verb) { PipeInputs& inputs = gPipeInputs; ParsePoisonOmissionKnob(); +#if MOBILEGL_PIPE_VERIFY + ArmVerify(); +#else + // The runtime knob without the compiled comparator is a no-op that would look green; + // this warning is what a lane's arming assertion turns into red. + if (MG_Config::Features.PipeVerify) { + MGLOG_W_ONCE("MGPipe: MOBILEGL_PIPE_VERIFY=1 requested but the comparator is not compiled in " + "(configure with -DMOBILEGL_PIPE_VERIFY=ON)"); + } +#endif #if MOBILEGL_PIPE_POISON MGPipeFilledState& filled = MGPipeFillAccess::Filled(inputs); // Starts at 1, so FilledGen == 0 means "never filled". @@ -415,5 +531,8 @@ namespace MobileGL::MG_Pipe { if (!IsOmitted(verb, field)) filled.FilledGen[i] = filled.CurrentVerbSerial; #endif } +#if MOBILEGL_PIPE_VERIFY + EntryCompare(inputs, mask); +#endif } } // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.h b/MobileGL/MG_Impl/Pipe/PipeFill.h index f433da9f..deda5991 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.h +++ b/MobileGL/MG_Impl/Pipe/PipeFill.h @@ -16,6 +16,8 @@ #if MOBILEGL_PIPE_PUSH #include namespace MobileGL::MG_Pipe { + struct PipeInputs; + // PipeFill.cpp. Bumps the per-verb serial, records the verb and the context identity, // and copies every field in the verb class's may-read mask (kMGPipeClassFieldMask) out // of the live GLContext, stamping each with the new serial. In a verify build it then @@ -29,6 +31,14 @@ namespace MobileGL::MG_Pipe { // fill; tests call it directly. Both null clears the omission. An unknown name is // Fatal{PipeVerifyBadKnob}. void MGPipeSetPoisonOmission(const char* verb, const char* field); + +#if MOBILEGL_PIPE_VERIFY + // PipeFill.cpp. The second arm of the comparator (P1 brief D8, ARCHITECTURE.md 13.2-2): + // fills `snapshot` from the live GLContext the old way, for every field in `mask`. This + // is the branch that survives P13, which is why it is its own function rather than the + // filler's loop. + void SnapshotFromGLContext(PipeInputs& snapshot, const MGPipeFieldMask& mask); +#endif } // namespace MobileGL::MG_Pipe #define MGP_FILL(Verb) ::MobileGL::MG_Pipe::MGPipeFillForVerb(::MobileGL::MG_Pipe::MGPipeVerb::Verb) #else