diff --git a/MobileGL/MG_IntegrationTest/Harness/SplitRuntimePeek.cpp b/MobileGL/MG_IntegrationTest/Harness/SplitRuntimePeek.cpp index 87c936c5..7cb8c851 100644 --- a/MobileGL/MG_IntegrationTest/Harness/SplitRuntimePeek.cpp +++ b/MobileGL/MG_IntegrationTest/Harness/SplitRuntimePeek.cpp @@ -47,7 +47,17 @@ namespace MGITest { if (session != nullptr) { // Encoder() returns the member; EmitSeq() returns m_emitSeq. Neither is a stub, and // neither emits anything - this is a read. - state.emitSeq = session->Encoder().EmitSeq(); + const MobileGL::MG_Remote::Wire::PipeWireEncoder& encoder = session->Encoder(); + state.emitSeq = encoder.EmitSeq(); + // The producer's ledger. Every one of these is a plain member read on the encoder + // or on the RingProducer it holds; none of them emits, publishes or waits, so a + // case may read them between two GL calls without changing what the next record is. + state.maxRecordBytes = encoder.MaxRecordBytesSeen(); + state.maxRecordBytesCap = encoder.MaxRecordBytesCap(); + state.cmdWraps = encoder.CmdWraps(); + state.cmdWrapPads = encoder.CmdWrapPads(); + state.cmdBytesWritten = encoder.CmdBytesWritten(); + state.stageReclaimWaits = encoder.StageReclaimWaits(); } #endif return state; diff --git a/MobileGL/MG_IntegrationTest/Harness/SplitRuntimePeek.h b/MobileGL/MG_IntegrationTest/Harness/SplitRuntimePeek.h index 3ee52772..9451f78e 100644 --- a/MobileGL/MG_IntegrationTest/Harness/SplitRuntimePeek.h +++ b/MobileGL/MG_IntegrationTest/Harness/SplitRuntimePeek.h @@ -70,6 +70,36 @@ namespace MGITest { unsigned int totalVerbSlots = 0; // The encoder's highest produced record ordinal, or 0 when there is no session. unsigned long long emitSeq = 0; + + // ---- the wire producer's ledger, for exit gates E3(e) and R-10's proof obligation --- + // + // All four are 0 when there is no session, which is why every case that reads them has + // to have passed SplitRuntimeSkipReason() first: 0 wraps in a process that never had a + // ring and 0 wraps in a process whose ring never filled are the same number and + // completely different facts. + // + // maxRecordBytes / maxRecordBytesCap: R-10 says P5 does no chunking and must prove it + // needs none. The cap is RingProducer::MaxRecordBytes() == MOBILEGL_IPC_RING_MB / 2, + // read from the ring this process actually got rather than recomputed from the + // environment. + // + // cmdWraps / cmdWrapPads / cmdBytesWritten: SEG_CMD cannot go round until more bytes + // have been written than the ring holds, so the byte count is the denominator without + // which the wrap count means nothing - "0 wraps" is a defect after 1.25 MiB through a + // 1 MiB ring and a tautology after 40 KiB. cmdWrapPads is the narrower R-9 event (a + // record STRADDLED the boundary and needed a kRecPad filler) and is recorded rather + // than asserted: a uniform record stride over a power-of-two ring lands on the + // boundary exactly and never straddles it. + // + // stageReclaimWaits: SEG_STAGE allocations that only fitted after the encoder reclaimed + // what the server had retired - P5's one real producer wait (see PipeWireCodec.h for + // why the command ring has none while the verb barrier is armed). + unsigned long long maxRecordBytes = 0; + unsigned long long maxRecordBytesCap = 0; + unsigned long long cmdWraps = 0; + unsigned long long cmdWrapPads = 0; + unsigned long long cmdBytesWritten = 0; + unsigned long long stageReclaimWaits = 0; }; SplitRuntimeState PeekSplitRuntime(); diff --git a/MobileGL/MG_IntegrationTest/Harness/WireLedgerChecks.h b/MobileGL/MG_IntegrationTest/Harness/WireLedgerChecks.h new file mode 100644 index 00000000..1c5eb512 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/WireLedgerChecks.h @@ -0,0 +1,146 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Harness/WireLedgerChecks.h +// Copyright (c) 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 +// +// THE TWO ASSERTIONS THE WIRE PRODUCER'S LEDGER MAKES POSSIBLE, in one place so the scenarios +// that carry them cannot drift apart on what the numbers mean. +// +// R-10 (ExpectMaxRecordBytesUnderCap). P5 does no chunking and must instead PROVE it never +// needs any: no record may exceed RingProducer::MaxRecordBytes() == MOBILEGL_IPC_RING_MB / 2. +// Half of that proof is already a Fatal - PipeWireCodec.cpp aborts Fatal{RingOverrun} on a +// record ABOVE the cap - and it is the loud half. The quiet half is the one this assertion +// covers: the phase has to publish the MAXIMUM ACTUALLY SEEN on a real workload, so that a +// record creeping towards the cap is visible before the day it crosses. Until this landed the +// only readers of PipeWireEncoder::MaxRecordBytesSeen() were codec unit cases over synthetic +// records, and the joint gate recorded BRIEF 8 item 3 as "Maximum record bytes: NO MEASUREMENT" +// (joint-v1.md 5). +// +// R-9 / exit gate E3(e) (ExpectSmallRingWrappedAtLeastOnce). The SmallRing lane exists to run +// the reduced path over a ring small enough to force the wrap path - the kRecPad filler that +// both sides must SKIP WITHOUT ADVANCING seq, which is R-9's last clause and the one piece of +// ring behaviour no other lane reaches. It ran green from the day it was registered and proved +// nothing, because nobody counted: measured on the joint head, a whole split scenario writes +// about 40 KiB into SEG_CMD (emitseq 22-71, maxrec 784 bytes), so a 1 MiB "small" ring is 25 +// times larger than the traffic and the head never comes near its wrap boundary. The lane and +// the default lane were the same run under two names - which is exactly the shape ID-53's own +// comment warned about and t1-v1.md carried as a debt ("WHAT THIS LANE DOES NOT YET ASSERT"). +// +// So the lane now DRIVES ENOUGH WORK to overrun its own ring before it asserts, and the +// assertion prints the denominator with the count: "0 wraps" is a defect after 1.25 MiB through +// a 1 MiB ring and a tautology after 40 KiB, and a bare `EXPECT_GE(wraps, 1)` cannot tell those +// apart. The driving is ordinary GL - clears and draws through the scenario's own objects - and +// not a hand-built record: R-16 forbids an assertion that constructs the state it observes, and +// a test that called Reserve directly would be asserting that the ring wraps, not that the +// WORKLOAD makes it wrap. +// +// WHAT IS ASSERTED IS THE HEAD GOING ROUND, NOT THE kRecPad FILLER, and that distinction was +// forced by a measurement rather than chosen: the first cut of this assertion read the pad +// count, and 1310824 bytes of clears and draws through a 1 MiB SEG_CMD produced one and a half +// trips round the ring and ZERO pads. The reason is arithmetic, not a defect - a workload whose +// records repeat at a uniform stride that divides a power-of-two capacity lands on the boundary +// exactly, every time - so a gate written against the pad count would have been red for the +// sizes in the record catalogue and green the day one of them changed. The pad count is +// RECORDED beside the wrap count (ringpads= on the stats line, in the JUnit properties, and in +// the session's teardown ledger) so the number is available without being load-bearing. + +#pragma once + +#include + +#include + +#include "SplitLane.h" +#include "SplitRuntimePeek.h" + +namespace MGITest::WireLedger { + + // The SmallRing lane declares MOBILEGL_IPC_RING_MB=1 (MG_IntegrationTest/CMakeLists.txt; + // 1 MiB is ConfigLoader's floor for it). The byte target below is that size plus a + // quarter: enough that the head MUST have crossed the wrap boundary, and far enough under + // the DEFAULT 8 MiB ring that the same workload cannot wrap there - which is what makes + // "raise the ring back to the default and this assertion goes red" a real control rather + // than a description. + inline constexpr unsigned long long kSmallRingLaneCmdByteTarget = (5ull << 20) / 4; // 1.25 MiB + + // A bound on the drive loop, so a lane whose records shrank cannot spin forever. It is + // generous on purpose: the loop's exit condition is the BYTE COUNT, and this only turns an + // infinite loop into a named failure. + inline constexpr unsigned int kSmallRingLaneMaxIterations = 200000u; + + // R-10's reading, for any split lane. `where` names the case, because the number is a + // MEASUREMENT this phase has to publish and a reader needs to know which workload produced + // it. + inline void ExpectMaxRecordBytesUnderCap(const char* where) { + const SplitRuntimeState state = PeekSplitRuntime(); + ASSERT_TRUE(state.sessionActive) + << "the wire ledger was read in a process with no client session; the caller must " + "pass SplitLane::SkipReasonForSplitOnlyAssertions() first, because every field of " + "this ledger is 0 there and 0 is also a legal measurement"; + // Not merely "under the cap": a maximum of ZERO means the case emitted no record at all, + // which satisfies `< cap` perfectly and is the exact shape of an emit table that + // resolved the transport and then fell through to the driver. + EXPECT_GT(state.maxRecordBytes, 0u) + << where << ": the largest record this session wrote is 0 bytes, so nothing crossed " + "SEG_CMD. R-10's proof obligation has no subject and the lane did not go " + "through the wire"; + EXPECT_LT(state.maxRecordBytes, state.maxRecordBytesCap) + << where << ": R-10 - the largest record this session wrote is " << state.maxRecordBytes + << " bytes and RingProducer::MaxRecordBytes() is " << state.maxRecordBytesCap + << " (half of a " << (state.maxRecordBytesCap * 2) + << " byte SEG_CMD, i.e. MOBILEGL_IPC_RING_MB). P5 does NOT chunk: a record at or " + "above the cap is Fatal{RingOverrun} at the encoder, and a maximum that has " + "climbed to it is the proof obligation failing. Report it to the integrator, who " + "decides between early chunking (P8) and a bigger default ring"; + ::testing::Test::RecordProperty("max_record_bytes", + static_cast(state.maxRecordBytes)); + ::testing::Test::RecordProperty("max_record_bytes_cap", + static_cast(state.maxRecordBytesCap)); + } + + // E3(e)'s reading. The CALLER drives the workload; this only reads the result, so that the + // thing being asserted about is the workload and not this header. + inline void ExpectSmallRingWrappedAtLeastOnce(const char* where, + unsigned long long bytesDriven) { + const SplitRuntimeState state = PeekSplitRuntime(); + ASSERT_TRUE(state.sessionActive) + << "the wire ledger was read in a process with no client session"; + const unsigned long long capacity = state.maxRecordBytesCap * 2; // MaxRecordBytes == cap/2 + // THE WRAP FIRST, AND THE DENOMINATOR RIGHT BEHIND IT, both as EXPECT so that a red + // carries both sentences. Order matters for what the failure SAYS: the thing this gate + // is about is the missing wrap, and "the loop pushed fewer bytes than the ring holds" + // is the EXPLANATION for it, not a different failure. An ASSERT on the denominator + // would print only the explanation and the reader would have to infer the gate - which + // is how the red-once line for this control was measured, and why it is written this + // way round. + EXPECT_GE(state.cmdWraps, 1u) + << where << ": exit gate E3(e) - " << bytesDriven << " bytes were written into a " + << capacity + << " byte SEG_CMD and the producer's head NEVER WENT ROUND: no wrap, so this entry " + "exercised exactly what the default lane exercises and the word SmallRing in its " + "name asserts nothing. That is what the joint gate recorded as 'SmallRing entries " + "ran, but no back-pressure wait count was measured' (joint-v1.md 6). The usual " + "cause is the ring: MOBILEGL_IPC_RING_MB did not reach this process, or the lane " + "was given the DEFAULT 8 MiB ring - which is exactly how this assertion was " + "proved to be load-bearing (R-16), by re-running this entry's own command with " + "MOBILEGL_IPC_RING_MB=8 and nothing else changed"; + EXPECT_GT(bytesDriven, capacity) + << where << ": and the reason is the denominator - the drive loop pushed only " + << bytesDriven << " bytes through a " << capacity + << " byte SEG_CMD, which cannot reach a wrap boundary at all. The loop stops at " + "kSmallRingLaneCmdByteTarget, which is sized for the 1 MiB ring this lane " + "declares (MGL_ITEST_GLES_SPLIT_SMALL_RING_ENVIRONMENT); a larger ring needs a " + "larger workload and is not what this lane is for"; + ::testing::Test::RecordProperty("ring_wraps", static_cast(state.cmdWraps)); + ::testing::Test::RecordProperty("ring_wrap_pads", static_cast(state.cmdWrapPads)); + ::testing::Test::RecordProperty("ring_waits", static_cast(state.stageReclaimWaits)); + ::testing::Test::RecordProperty("cmd_bytes_written", static_cast(bytesDriven)); + } + + // Bytes the producer has written so far, or 0 outside a split process. + inline unsigned long long CmdBytesWritten() { return PeekSplitRuntime().cmdBytesWritten; } + +} // namespace MGITest::WireLedger diff --git a/MobileGL/MG_IntegrationTest/Scenarios/TriangleScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/TriangleScenario.cpp index 7f0e67ce..520615c0 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/TriangleScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/TriangleScenario.cpp @@ -48,6 +48,8 @@ #include "../Harness/HeadlessGL.h" #include "../Harness/ScenarioFixture.h" +#include "../Harness/SplitLane.h" +#include "../Harness/WireLedgerChecks.h" #ifdef GLAPI #undef GLAPI @@ -163,6 +165,32 @@ void main() { oColor = vec4(vColor, 1.0); } EXPECT_TRUE(RegionIsMostly(image, 0, (w * 5) / 100, 0, (h * 5) / 100, color, 0.0, when)); } + // EXIT GATE E3(e)'s DRIVE LOOP. Ordinary GL through this scenario's own objects - + // a clear and a VBO-backed draw per iteration, no readback (a readback is a + // SEG_REPLY round trip per iteration and would make this cost seconds rather than + // milliseconds) - repeated until the producer has written more bytes into SEG_CMD + // than the lane's ring holds. Returns the bytes this loop drove. + // + // NOTHING HERE TOUCHES THE RING DIRECTLY. The loop's only input is the producer's + // own head cursor, read through Harness/SplitRuntimePeek, and its only output is + // GL calls the scenario already makes. R-16: an assertion may not construct the + // state it observes, and "the workload makes the ring wrap" is a different claim + // from "a test can make the ring wrap". + unsigned long long DriveUntilSmallRingOverruns() { + const unsigned long long before = WireLedger::CmdBytesWritten(); + unsigned long long driven = 0; + for (unsigned int i = 0; i < WireLedger::kSmallRingLaneMaxIterations; ++i) { + ClearTo(0.0f, 0.0f, (i & 1u) ? 1.0f : 0.0f, 1.0f); + glUseProgram(m_program); + glBindVertexArray(m_vao); + glDrawArrays(GL_TRIANGLES, 0, 3); + // Cheap: this is a member read on the encoder, not a wire round trip. + driven = WireLedger::CmdBytesWritten() - before; + if (driven > WireLedger::kSmallRingLaneCmdByteTarget) break; + } + return driven; + } + unsigned int m_program = 0; unsigned int m_vao = 0; unsigned int m_vbo = 0; @@ -205,6 +233,41 @@ void main() { oColor = vec4(vColor, 1.0); } "re-specification of either"); ExpectClearedCorner(second, "black", "frame 1's clear, which is the only thing that changed"); Gl().EndFrame(); + + // ---- the split lanes' two readings of the wire producer's ledger -------------------- + // + // They are HERE, at the end of the steady-state case, and not in a case of their own, + // for a reason that is about the gate and not about tidiness: `integration-split` is a + // NAMED census (19 ran / 2 skipped by design) and a new entry moves it, so the phase + // would have to re-baseline a number the joint gate just pinned. The measurement wants + // this workload anyway - BRIEF 8 item 3 names this case - and a reading taken after the + // case's own pixel assertions is a reading over a run that is known to have been + // correct. + // + // Both are skipped, loudly and by the same predicate every other split-only assertion + // uses, in the monolith lanes: there is no encoder there, and every field of the + // ledger reads 0. + const std::string skip = SplitLane::SkipReasonForSplitOnlyAssertions(); + if (!skip.empty()) { + RecordProperty("wire_ledger_skip_reason", skip); + return; + } + + // R-10's proof obligation over target B. Published in every split lane, small ring + // included - the cap moves with MOBILEGL_IPC_RING_MB, so the SmallRing lane is also the + // arm where a record closest to its cap would show up first. + WireLedger::ExpectMaxRecordBytesUnderCap( + "TriangleScenario.TheSameVboAndVaoRedrawAcrossAFrameBoundary"); + + // Exit gate E3(e). Only the small-ring lane drives the overrun: at the default 8 MiB + // the same loop would take eight times as long to say the same thing, and the point of + // the lane is that IT is the arm with a ring the workload can fill. + if (SplitLane::IsSmallRingLane()) { + const unsigned long long driven = DriveUntilSmallRingOverruns(); + Gl().EndFrame(); + WireLedger::ExpectSmallRingWrappedAtLeastOnce( + "TriangleScenario.TheSameVboAndVaoRedrawAcrossAFrameBoundary", driven); + } } } // namespace MGITest