[Test] (MG_IntegrationTest): exit gate E3(e) drives the small ring past its own capacity and asserts the wrap, and both split arms of TriangleScenario publish R-10's maximum record bytes against the ring's cap (ID-65)

This commit is contained in:
2026-09-16 11:09:15 -04:00
parent 9f52878313
commit a7b43cbbcd
4 changed files with 250 additions and 1 deletions
@@ -47,7 +47,17 @@ namespace MGITest {
if (session != nullptr) { if (session != nullptr) {
// Encoder() returns the member; EmitSeq() returns m_emitSeq. Neither is a stub, and // Encoder() returns the member; EmitSeq() returns m_emitSeq. Neither is a stub, and
// neither emits anything - this is a read. // 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 #endif
return state; return state;
@@ -70,6 +70,36 @@ namespace MGITest {
unsigned int totalVerbSlots = 0; unsigned int totalVerbSlots = 0;
// The encoder's highest produced record ordinal, or 0 when there is no session. // The encoder's highest produced record ordinal, or 0 when there is no session.
unsigned long long emitSeq = 0; 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(); SplitRuntimeState PeekSplitRuntime();
@@ -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 <string>
#include <gtest/gtest.h>
#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<int>(state.maxRecordBytes));
::testing::Test::RecordProperty("max_record_bytes_cap",
static_cast<int>(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<int>(state.cmdWraps));
::testing::Test::RecordProperty("ring_wrap_pads", static_cast<int>(state.cmdWrapPads));
::testing::Test::RecordProperty("ring_waits", static_cast<int>(state.stageReclaimWaits));
::testing::Test::RecordProperty("cmd_bytes_written", static_cast<int>(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
@@ -48,6 +48,8 @@
#include "../Harness/HeadlessGL.h" #include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h" #include "../Harness/ScenarioFixture.h"
#include "../Harness/SplitLane.h"
#include "../Harness/WireLedgerChecks.h"
#ifdef GLAPI #ifdef GLAPI
#undef 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)); 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_program = 0;
unsigned int m_vao = 0; unsigned int m_vao = 0;
unsigned int m_vbo = 0; unsigned int m_vbo = 0;
@@ -205,6 +233,41 @@ void main() { oColor = vec4(vColor, 1.0); }
"re-specification of either"); "re-specification of either");
ExpectClearedCorner(second, "black", "frame 1's clear, which is the only thing that changed"); ExpectClearedCorner(second, "black", "frame 1's clear, which is the only thing that changed");
Gl().EndFrame(); 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 } // namespace MGITest