Merge branch 'feat/disaggregated' into p5/c1

# Conflicts:
#	MobileGL/MG_Remote/Client/ClientSession.cpp
#	MobileGL/MG_Test/Wire/CMakeLists.txt
This commit is contained in:
2026-09-16 07:23:35 -04:00
35 changed files with 2640 additions and 396 deletions
+65 -84
View File
@@ -1074,57 +1074,32 @@ jobs:
# Split entries SKIP and ctest reports green whatever the knob says, so an unconditional
# control would be red for the whole of P5 for a reason that is not a defect.
#
# So the expected state is DERIVED rather than assumed, from the same fact the lanes derive
# it from: MG_IntegrationTest/CMakeLists.txt puts MGITEST_REMOTE_CLIENT_PRESENT=1 into the
# Split entries' ENVIRONMENT exactly when MG_Remote carries no c0 signature stub, and that
# string is in the generated ctest include files this artifact ships. When it is there the
# controls MUST fire; when it is not, the step says so loudly and does not pretend.
# So the expected state is DERIVED FROM BEHAVIOUR rather than assumed. The first version read
# MGITEST_REMOTE_CLIENT_PRESENT out of the generated *_tests.cmake, which was a restatement of
# the CMake source probe review finding M-1 falsified; the arming condition is a runtime fact
# inside each test process (MG_Config::Transport, ClientSession::Active() and
# ImplementedVerbCount(), read by Harness/SplitRuntimePeek), so the only honest way to ask it
# from a shell is to look at what the entries DID. When entries passed, the controls MUST
# fire; when every one of them skipped, the step says so loudly and does not pretend.
#
# THE BODY OF THIS STEP IS scripts/ci/split_negative_controls.sh, and the move is the point
# rather than tidiness. A `run:` block executes nowhere but on a runner, so these lines were
# unreviewable and untestable: when the wave-1 cross-family review said they were broken,
# CONFIRMING it needed a hand-made copy of them (wave1-codex-verify.md 8), and a copy is not
# the thing. scripts/ci/control_smoke_test.sh now drives the very file this step runs.
#
# What that smoke test pins, and what ID-46 finding 8 found missing: each control asserts its
# OWN failure reason. A non-zero ctest exit used to be enough, so a timeout, a setup abort or
# any unrelated assertion printed "turned N selected entries red, as it must" and this step
# went green. The arming run's `|| true` had the matching defect - it counted a case that ran
# and FAILED as evidence the lane was live, so the controls could be measured against a
# baseline that was already red.
- name: Negative controls - the verb barrier and the persistent-map push must be load-bearing
working-directory: build-split
env:
MOBILEGL_ITEST_REQUIRE_GPU: "1"
run: |
# THE ARMED STATE IS DERIVED FROM BEHAVIOUR, not from a marker string in the generated
# ctest files. The first version read MGITEST_REMOTE_CLIENT_PRESENT out of
# *_tests.cmake, which was a restatement of the CMake source probe review finding M-1
# falsified; the arming condition is now a runtime fact inside each test process, so the
# only honest way to ask it from a shell is to look at what the entries DID.
ctest -L integration-split -j 4 --no-tests=error --output-junit "${RUNNER_TEMP}/isplit.xml" || true
armed=$(python3 - "${RUNNER_TEMP}/isplit.xml" <<'PY'
import sys, xml.etree.ElementTree as ET
ran = 0
for case in ET.parse(sys.argv[1]).getroot().iter('testcase'):
if case.find('skipped') is None and case.get('status') not in ('notrun', 'disabled'):
ran += 1
print(ran)
PY
)
echo "split entries that actually ran: ${armed}"
if [ "${armed}" -lt 1 ]; then
echo "::warning::every DirectGLES.Split. entry SKIPPED, so neither negative control can fire. The arming condition is a runtime fact - MG_Config::Transport, ClientSession::Active() and ImplementedVerbCount(), read by Harness/SplitRuntimePeek - and it becomes true on the commit that lands the last of c1/s1/v1. This step becomes a gate then, with no edit; it is not a green that asserted anything today."
exit 0
fi
run_control() {
name="$1"; filter="$2"; shift 2
matched=$(ctest -N -L integration-split -R "${filter}" | grep -cE '^ *Test *#[0-9]+:')
if [ "${matched}" -lt 1 ]; then
echo "::error::${name} selected ${matched} tests; its filter no longer matches anything"
exit 1
fi
if env "$@" ctest --output-on-failure -L integration-split -R "${filter}" --no-tests=error; then
echo "::error::${name} left ${matched} split entries GREEN, so the knob it turns is not load-bearing and the gate it controls proves nothing."
exit 1
fi
echo "${name} turned ${matched} selected entries red, as it must"
}
# E1: R-1's lockstep verb barrier. Without it the client keeps pulling fields from a live
# GLContext while the server runs ahead, so the server reads future values.
run_control "negative control E1 (MOBILEGL_IPC_VERB_BARRIER=0)" \
'DirectGLES\.Split\.(Triangle|ClearThenReadPixels)' MOBILEGL_IPC_VERB_BARRIER=0
# E3(a): the persistent-map push. 0 is admitted by ConfigLoader on purpose and is
# documented there as this control.
run_control "negative control E3(a) (MOBILEGL_IPC_PERSISTENT_BLOCK_KB=0)" \
'DirectGLES\.Split\.PersistentCoherentMapScenario' MOBILEGL_IPC_PERSISTENT_BLOCK_KB=0
CONTROL_TMPDIR: ${{ runner.temp }}
run: bash "${GITHUB_WORKSPACE}/scripts/ci/split_negative_controls.sh"
- name: Upload split lane logs
if: always()
@@ -1959,37 +1934,25 @@ jobs:
# The rerun replays into the same case directory, so the good run's images are put aside and
# restored whichever way the control goes; "Upload actual image" below runs `if: always()`
# and would otherwise ship the deliberately-wrong run's output under the good run's name.
# THE BODY OF THIS STEP IS scripts/ci/retrace_pull_library_control.sh, for the reason the
# split lane's control gives: a `run:` block cannot be executed off a runner, so these lines
# could not be tested until they ran in CI. scripts/ci/control_smoke_test.sh drives that file.
#
# Two holes ID-46 finding 8(b) found in this block, both CONFIRMED against the REAL ctest in a
# REAL build tree, both closed in the script: it had NO selection guard at all - unlike the
# split lane's run_control - so a case/backend regex matching nothing exited 8 through
# `--no-tests=error` and was read as "the pull library turned it red"; and only "non-zero
# ctest" was checked after the nm identity check, so a loader failure, a missing fixture or a
# timeout passed it. The red must now carry run_trace_case.cmake's own sentence.
- name: Negative control - the PULL library must red this split retrace
working-directory: build-retrace/tools/trace_replay
run: |
set +e
GOOD_OUTPUT="${RUNNER_TEMP}/split-verified-output"
rm -rf "${GOOD_OUTPUT}"
if [ -d "${{ matrix.case }}" ]; then cp -a "${{ matrix.case }}" "${GOOD_OUTPUT}"; fi
# The pull library, unpacked from build-linux's artifact, over the frozen path every
# case has baked in. It defines no MG_Remote symbol, so ConfigLoader has no transport
# parser and MOBILEGL_TRANSPORT=inproc is accepted and ignored - the exact shape of "the
# split lane ran monolith".
cp "${GITHUB_WORKSPACE}/pull-runtime/build-linux/libMobileGL.so" \
"${GITHUB_WORKSPACE}/build-linux/libMobileGL.so"
if nm --defined-only "${GITHUB_WORKSPACE}/build-linux/libMobileGL.so" | grep -q -i MG_Remote; then
echo "::error::the control's own library defines MG_Remote symbols, so it is not a pull build and this control would prove nothing"
exit 1
fi
export MOBILEGL_TRANSPORT=inproc
ctest -V --no-tests=error --timeout 10800 \
-R "^MobileGLTraceReplay\.${{ matrix.case }}\.${{ matrix.backend }}$"
control_rc=$?
set -e
if [ -d "${GOOD_OUTPUT}" ]; then
rm -rf "${{ matrix.case }}"; mv "${GOOD_OUTPUT}" "${{ matrix.case }}"
echo "restored the verified run's output over the control's"
fi
if [ "${control_rc}" -eq 0 ]; then
echo "::error::a PULL library passed the split retrace. OpenRA scores ssim 1.000000 under a monolith library too (measured), so the picture is not and cannot be this lane's gate - run_trace_case.cmake's transport-resolution assertion is, and it has stopped working. Every green in this job is then a monolith run under a name that says split."
exit 1
fi
echo "the pull library turned the split retrace red, as it must (ctest exit ${control_rc})"
env:
CONTROL_TMPDIR: ${{ runner.temp }}
PULL_LIBRARY: ${{ github.workspace }}/pull-runtime/build-linux/libMobileGL.so
FROZEN_LIBRARY: ${{ github.workspace }}/build-linux/libMobileGL.so
run: >-
bash "${GITHUB_WORKSPACE}/scripts/ci/retrace_pull_library_control.sh"
'${{ matrix.case }}' '${{ matrix.backend }}'
# The refusal census, recorded rather than gated. run_trace_case.cmake already REDS the case
# on any Fatal{, so reaching here means the count is zero - but the number and the distinct
@@ -2314,6 +2277,21 @@ jobs:
python3 scripts/gen_pipe_field_ownership.py --check
python3 scripts/gen_pipe_field_ownership.py --self-test
# R-16 APPLIED TO THE NEGATIVE CONTROLS THEMSELVES. The split lane's E1/E3(a) controls and the
# retrace lane's pull-library control are gates, and until ID-46 finding 8 neither could be
# made red by anyone: their bodies were `run:` blocks, which execute only on a runner. Both
# bodies now live in scripts/ci/, and this step runs them against a stubbed ctest that
# reproduces the finding - a NON-EMPTY selection failing with UNRELATED_CONTROL_FAILURE, and a
# case/backend regex matching no tests - and requires each control to report FAILED. The same
# stub, failing with the diagnostics the scenarios really emit, must make them report PASSED.
#
# NO BRANCH GUARD: this asks "do the negative controls still reject a red that is not theirs",
# which is a question every branch can answer and none of which depends on the TEMPORARY
# feat/disaggregated trigger at the top of this file. It costs a couple of seconds and needs
# no build.
- name: The split and retrace negative controls still reject a red that is not theirs (R-16)
run: bash scripts/ci/control_smoke_test.sh
# A GATE as of P3a (G5). "pool 与延迟释放原样搬" (ROADMAP.md:19) is meant literally: the
# buffer pool, the deferred-release drain and the three persistently mapped rings move
# VERBATIM, and ARCHITECTURE.md:515 says why - their retire happens only inside Present, so
@@ -2325,8 +2303,9 @@ jobs:
# per preprocessor arm, and a push build compiles only FlushPendingRangesFrom while the
# untouched FlushPendingRangesNow lives in the `#else`. Hashing the pull name alone would
# protect text no shipping build compiles, so both are hashed - the pull ladder against
# BASELINE, the push ladder against a sha pinned in the script at 3e298c9a, because that
# one was born in P3a and has no body at the base ref to compare with.
# BASELINE, the push ladder against a sha pinned in the script at 3dadd4c1 (ID-41), because
# that one was born in P3a and is compared against the reviewed body rather than against the
# base ref.
#
# Scoped to the disaggregation branch and to a manual dispatch, deliberately: the question
# is "did these eleven move since P3a started", and BASELINE is P3a's base ref. On dev,
@@ -2336,9 +2315,10 @@ jobs:
#
# --self-test is the half that keeps it honest, and it is not optional: a comparison that
# silently stopped comparing produces exactly the same green as eleven untouched bodies. It
# runs six canned controls - eleven bodies extracted, an untouched copy compared equal, an
# edit OUTSIDE them ignored, and each of the three perturbation targets (ClearBufferPool
# and BOTH flush ladders) reported BY NAME - and fails if any of them does not answer.
# runs eight canned controls - eleven bodies extracted, an untouched copy compared equal, an
# edit OUTSIDE them ignored, each of the three perturbation targets (ClearBufferPool and BOTH
# flush ladders) reported BY NAME, plus the pin-precedence control and a one-token edit to the
# pinned ladder compared against the pin - and fails if any of them does not answer.
# Same shape as gen_pipe.py --self-test above.
- name: The buffer pool, the deferred-release drain and the rings did not move (G5)
if: ${{ github.ref == 'refs/heads/feat/disaggregated' || github.event_name == 'workflow_dispatch' }}
@@ -2369,10 +2349,11 @@ jobs:
# --self-test is the half that keeps it honest and is not optional: a comparison that silently
# stopped comparing produces exactly the same green as seventeen untouched regions. It runs
# three positive controls (seventeen regions extracted, an untouched copy compared equal, an
# edit OUTSIDE them invisible in all three files) and FOUR negative ones - ClearBufferPool,
# edit OUTSIDE them invisible in all three files) and NINE negative ones - ClearBufferPool,
# FlushPendingRangesNow, RecomputeBackendColorSlots and StageBlocksIntoUnpackRing, each
# perturbed on its own and each required to be named BY NAME - and fails if any of them does
# not answer.
# perturbed at its HEAD and at its TAIL and each required to be named BY NAME, plus a
# one-token edit to the pinned FlushPendingRangesFrom body compared against the pin - and
# fails if any of them does not answer.
- name: The unpack ring, the attachment permutation, the D24S8 core and the format caveat did not move (G5)
if: ${{ github.ref == 'refs/heads/feat/disaggregated' || github.event_name == 'workflow_dispatch' }}
run: bash scripts/p4a_untouched_regions.sh "${BASELINE}" HEAD
@@ -43,6 +43,16 @@
// address really is valid in this process, which is precisely why E3(d) is worth gating at all -
// and a direct count of declines would need a counter from packages b1/v1.
//
// AND MEMBERSHIP FOLLOWS THE ARM (ID-42). The client's live-persistent-map set is the set of maps
// the client still has to PUSH, so an ADOPTED store is deliberately not in it -
// PersistentMapTracker::IsLivePersistentMap's second row is IsBackendPersistentMapped(), and an
// adopted store's bytes are already in coherent GPU memory. AssertMembership therefore expects
// `live == (arm == emulated)`, from the same peek AssertOrRecordArm reads. The arm-INDEPENDENT
// claim - "the predicate reads the chain, not the adopt tier" - belongs to a unit case that can
// drive both answers on demand (SplitBufferSet.
// TheAdoptedArmIsNotAMemberAndItIsTheChainRowThatSaysSo) and not to an integration entry, which
// only ever sees whichever arm its driver and transport happen to give it.
//
// `mpr` (map-persistent-roundtrips) is counted per ACQUISITION ATTEMPT, mint or decline
// (ARCHITECTURE.md:492), so it is the SAME NUMBER on both arms and in both transports: that is
// what makes exit gate E3(c)'s "mpr equal to the monolith arm's" checkable by one process. Both
@@ -243,6 +253,11 @@ void main() { oColor = vec4(vColor, 1.0); }
ASSERT_TRUE(PeekBufferIsAdoptedPersistentMap(vbo, &adopted))
<< "no frontend BufferObject behind GL buffer " << vbo << " " << when;
const char* arm = adopted ? "adopted" : "emulated";
// REMEMBERED, because AssertMembership's expectation is a function of it (ID-42)
// and the two have to be talking about ONE observation of ONE buffer. A second
// peek would be a second question, and a scenario that asked it twice could
// straddle a change and then compare two different moments.
m_observedArm = adopted ? ObservedArm::Adopted : ObservedArm::Emulated;
RecordProperty("persistent_map_arm", arm);
if (declared.empty()) return;
ASSERT_EQ(declared, std::string(arm))
@@ -259,21 +274,63 @@ void main() { oColor = vec4(vColor, 1.0); }
// b1-v1.md 4.1 item 2: the client's live-persistent-map set is meant to be exactly
// SyncPersistentMappedRange's early-out chain. A drift between the two stops the push
// silently; asking here makes it a named failure instead.
//
// MEMBERSHIP IS A PROPERTY OF THE ARM, NOT OF THE FLAGS - INTEGRATOR DECISION ID-42,
// and the first cut of this function got it wrong in a way no lane could see. It
// asserted `live` UNCONDITIONALLY, on the grounds that "a PERSISTENT|WRITE|COHERENT map
// that is not FLUSH_EXPLICIT is a member by construction". That sentence is true only
// on the EMULATED arm. PersistentMapTracker::IsLivePersistentMap's second row is
// `if (buffer.IsBackendPersistentMapped()) return false;` - deliberately, because the
// predicate must read the CHAIN and not the tier: an adopted store's bytes are already
// in coherent GPU memory and there is nothing for the push to ship, so it is not a
// member and must not be one. On the adopted arm `live` is false BY DESIGN.
//
// It survived the first wave because the two builds that ran it could not contradict
// it: the push build compiles no MG_Remote, so MGITEST_PERSISTENT_MAP_TRACKER is
// undefined and this function returned at the line above before asserting anything;
// the split build's monolith lane, where the tracker IS compiled, adopts
// (MGPipeApplyMapPersistent's R-6 decline is ANDed with `Transport != Monolith`,
// PipeApply.cpp:2005), and seven entries went red the first time the assertion ran at
// all.
//
// So the expectation is `live == (arm == emulated)`, taken from the SAME peek
// AssertOrRecordArm read. The arm-independent half - "the predicate reads the chain,
// not the tier" - is not an integration claim and is pinned where it can be driven
// directly, by MG_Test/Buffer/SplitBufferTest.cpp's
// SplitBufferSet.TheAdoptedArmIsNotAMemberAndItIsTheChainRowThatSaysSo.
void AssertMembership(unsigned int vbo, const char* when) {
if (!PersistentMapTrackerAvailable()) {
RecordProperty("persistent_map_membership", "unavailable");
return;
}
if (m_observedArm == ObservedArm::NotLookedAt) {
// AssertOrRecordArm could not look, so there is no arm to condition on and
// "could not look" is not "it was emulated" (PersistentMapPeek.h). A lane that
// DECLARES an arm has already been skipped by AssertOrRecordArm in this case.
RecordProperty("persistent_map_membership", "arm unknown");
return;
}
const bool emulated = (m_observedArm == ObservedArm::Emulated);
bool live = false;
ASSERT_TRUE(PeekBufferIsLivePersistentMap(vbo, &live))
<< "the tracker is compiled in but could not answer for GL buffer " << vbo << " " << when;
EXPECT_TRUE(live)
<< "a PERSISTENT|WRITE|COHERENT map that is not FLUSH_EXPLICIT is a member of "
"the client's live-persistent-map set by construction, and it is not one "
<< when
RecordProperty("persistent_map_membership", live ? "member" : "not a member");
EXPECT_EQ(live, emulated)
<< "the client's live-persistent-map set is the set of maps the client still has "
"to PUSH, so membership follows the arm: on the emulated arm this "
"PERSISTENT|WRITE|COHERENT non-FLUSH_EXPLICIT map must be a member, and on "
"the adopted arm it must not be - IsLivePersistentMap's "
"IsBackendPersistentMapped() row takes it out, because an adopted store's "
"bytes are already in coherent GPU memory and there is nothing to ship. This "
"map landed in the "
<< (emulated ? "emulated" : "adopted") << " arm " << when << " and the predicate "
<< (live ? "made it a member" : "did not make it a member")
<< ". The set is supposed to BE SyncPersistentMappedRange's early-out chain; if "
"they have drifted, the push stops shipping this buffer's blocks and nothing "
"else says so.";
"they have drifted, the push either stops shipping this buffer's blocks or "
"starts shipping an adopted store's, and nothing else says so. (ID-42. The "
"arm-independent statement - that the predicate reads the chain and not the "
"adopt tier - is pinned by SplitBufferSet."
"TheAdoptedArmIsNotAMemberAndItIsTheChainRowThatSaysSo, not here.)";
}
void TearDown() override {
@@ -320,6 +377,13 @@ void main() { oColor = vec4(vColor, 1.0); }
return RegionIsMostly(image, 8, image.Width() - 9, 8, image.Height() - 9, color, 0.0, when);
}
// The arm this scenario OBSERVED, as opposed to the one its lane declared. A lane may
// declare none (the monolith counting lane does not), and the peek may be unable to
// look at all, so the third state is not "assume emulated" - it is "there is no arm to
// condition on", and AssertMembership records rather than asserts under it.
enum class ObservedArm { NotLookedAt, Adopted, Emulated };
ObservedArm m_observedArm = ObservedArm::NotLookedAt;
unsigned int m_program = 0;
unsigned int m_vao = 0;
unsigned int m_vbo = 0;
+1 -1
View File
@@ -144,7 +144,7 @@ These three are the reason `kHasBlob` had to be given an exact meaning (table 0)
|---|---|---|---|---|
| 21 | `MapPersistent` (5) | `kReplySlot\|kOptional` | **Returns `nullptr` under split, always** (R-6/R-2.4). Its `const void* seedBytes` companion (`PipeApply.h:917`) therefore never crosses in P5 and needs no carrier. The three frontend sites already tolerate a decline (`BufferObject.cpp:238`, `:603-606`, `:657-660`). Answer travels as `Status = DECLINED` with a zero-length payload. | `map_persistent.decline` |
| 22 | `ResourceReadback` (52) | `kReplySlot` | Bytes go **server → client** in `SEG_EVENT` via `OnBufferWriteback` (#3), not in the reply slot: the destination is the client's shadow and the size is the resource's, not a fixed slot's. The reply slot carries only completion. **The ordering rule is load-bearing:** the writeback is applied **before** the mutation epoch bumps, never after (`ARCHITECTURE.md:292-294`, `Managers.cpp:2120-2136`). | `resource_readback.done` |
| 23 | `ReadPixels` (58) / `GetTextureImage` (55) | `kReplySlot` | **`ReadPixels` blocks in P5** and its pixels come back in the reply slot, which is why `ReplyPool::SlotBytes()` is sized from the scenario's largest read rather than guessed. `MGPReadbackInfo` has `DstOffset`/`DstSize` but **no `Seg`** (`MGPipeTypes.h:1197-1206`): ruling — the destination is **always `SEG_REPLY`** in P5, so no `Seg` field is added; the PBO destination (fire-and-forget plus a client-side `MarkGpuWritten`) is b1's and also needs none, because a PBO destination is a resource handle rather than a segment. `GetTextureImage` is **not on P5's reduced path** and its slot stays `Fatal{UnmigratedVerb}`. | `read_pixels.pixels` |
| 23 | `ReadPixels` (58) / `GetTextureImage` (55) | `kReplySlot` | **`ReadPixels` blocks in P5** and its pixels come back in the reply slot, which is why `ReplyPool::SlotBytes()` is sized from the scenario's largest read rather than guessed. **ID-47: `SEG_REPLY` is 16 MiB, eight slots of 2 MiB, `MaxReplyBytes = 2 MiB 16 = 2,097,136`** — the canonical sizes are 8/32/16 MiB + 256 KiB (`ProtocolSmokeTest` pins them on the wire, `SessionTest` on the mapping); the largest P5 read is E2's full-surface 640×480 RGBA8 snapshot, 1,228,800 bytes, which the previous 8 MiB pool refused. **A read whose answer would exceed `MaxReplyBytes` is refused at the CLIENT before emission** with `Fatal{ReplyTooLarge, "ReadPixels <w>x<h> <format> <bytes> > <cap>"}` (`ReplySlotPool::RequireReadPixelsFits`, forwarded by `ClientSession::RequireReadPixelsReplyFits`, called once by c1's `OnReadPixels` emitter) — never truncated, never a server-side abort the client cannot name; `Post`'s own refusal stays as the last line of defence. Reads larger than 2 MiB (a 2400×1080 RGBA8 device surface) are a P6 debt, not a bigger pool; §5 has no knob for this segment by design. `MGPReadbackInfo` has `DstOffset`/`DstSize` but **no `Seg`** (`MGPipeTypes.h:1197-1206`): ruling — the destination is **always `SEG_REPLY`** in P5, so no `Seg` field is added; the PBO destination (fire-and-forget plus a client-side `MarkGpuWritten`) is b1's and also needs none, because a PBO destination is a resource handle rather than a segment. `GetTextureImage` is **not on P5's reduced path** and its slot stays `Fatal{UnmigratedVerb}`. | `read_pixels.pixels` |
---
+19 -29
View File
@@ -175,21 +175,6 @@ namespace MobileGL::MG_Remote {
constexpr Uint64 kFormatCells = static_cast<Uint64>(MG_Backend::kFormatCapabilityTargetCount) *
static_cast<Uint64>(MG_Backend::kFormatCapabilityFormatCount);
// FNV-1a, the same mixer the tree already uses for build-stamp style fingerprints.
constexpr Uint64 kFnvOffset = 1469598103934665603ull;
constexpr Uint64 kFnvPrime = 1099511628211ull;
Uint64 FnvBytes(Uint64 hash, const void* bytes, SizeT size) {
const auto* p = static_cast<const Uint8*>(bytes);
for (SizeT i = 0; i < size; ++i) {
hash ^= static_cast<Uint64>(p[i]);
hash *= kFnvPrime;
}
return hash;
}
Uint64 FnvU64(Uint64 hash, Uint64 value) { return FnvBytes(hash, &value, sizeof(value)); }
} // namespace
// ---------------------------------------------------------------------------------
@@ -469,7 +454,7 @@ namespace MobileGL::MG_Remote {
// The ABI assertion the handshake carries
// ---------------------------------------------------------------------------------
Uint64 CapsAbiFingerprint() {
Transport::AbiFingerprintInputs CapsAbiFingerprintInputs() {
// MGPCaps has only a COMPOSITIONAL size assertion (MGPipeTypes.h:145-146) because
// DynamicBackendParameters still carries SizeT and GLenum members - P0.5's fixed-width
// rewrite did not happen and P5 does not do it either (table 0's ABI row; the rewrite
@@ -479,23 +464,28 @@ namespace MobileGL::MG_Remote {
// The git stamp is in it because two builds of the same sizes can still disagree about
// a FIELD ORDER, which no sizeof can see; P6's spawn is same-machine and same-binary,
// so it inherits this unchanged rather than needing a looser rule.
Uint64 hash = kFnvOffset;
hash = FnvU64(hash, sizeof(MG_Backend::DynamicBackendParameters));
hash = FnvU64(hash, sizeof(MG_Pipe::MGPCaps));
hash = FnvU64(hash, sizeof(MG_Backend::GLFunctionsTable));
hash = FnvU64(hash, static_cast<Uint64>(MG_Backend::kFormatCapabilityTargetCount));
hash = FnvU64(hash, static_cast<Uint64>(MG_Backend::kFormatCapabilityFormatCount));
hash = FnvU64(hash, kFormatCapabilitiesCodecVersion);
hash = FnvU64(hash, kRendererInfoCodecVersion);
hash = FnvU64(hash, static_cast<Uint64>(MG_Pipe::MGPWireOp::kOpCount));
Transport::AbiFingerprintInputs inputs;
inputs.DynamicParamsSize = sizeof(MG_Backend::DynamicBackendParameters);
inputs.CapsSize = sizeof(MG_Pipe::MGPCaps);
inputs.FunctionTableSize = sizeof(MG_Backend::GLFunctionsTable);
inputs.FormatCapabilityTargets = static_cast<Uint64>(MG_Backend::kFormatCapabilityTargetCount);
inputs.FormatCapabilityFormats = static_cast<Uint64>(MG_Backend::kFormatCapabilityFormatCount);
inputs.FormatCapabilitiesCodecVersion = kFormatCapabilitiesCodecVersion;
inputs.RendererInfoCodecVersion = kRendererInfoCodecVersion;
inputs.OpCount = static_cast<Uint64>(MG_Pipe::MGPWireOp::kOpCount);
// The declared protocol ABI, carried over from s1's version of this function at
// integration (ID-33). The sizeofs above catch a struct that changed shape; this
// catches a peer that changed the PROTOCOL while every struct stayed the same size,
// which is the one break the rest of the mix is blind to.
hash = FnvU64(hash,
static_cast<Uint64>(MOBILEGL_ABI_VERSION(MOBILEGL_PROTOCOL_ABI_MAJOR, MOBILEGL_PROTOCOL_ABI_MINOR)));
hash = FnvBytes(hash, GIT_COMMIT_HASH_SHORT, std::strlen(GIT_COMMIT_HASH_SHORT));
return hash;
inputs.AbiVersion =
static_cast<Uint32>(MOBILEGL_ABI_VERSION(MOBILEGL_PROTOCOL_ABI_MAJOR, MOBILEGL_PROTOCOL_ABI_MINOR));
inputs.BuildStamp = GIT_COMMIT_HASH_SHORT;
return inputs;
}
// ONE implementation, deliberately a one-liner: the wave-1 review (ID-46 finding 6) found a
// second hand-rolled FNV loop here while the mixer under Transport/ had no production caller,
// so the sensitivity test could not see this function change. Now it starts from here.
Uint64 CapsAbiFingerprint() { return Transport::MixAbiFingerprint(CapsAbiFingerprintInputs()); }
} // namespace MobileGL::MG_Remote
+13 -4
View File
@@ -34,6 +34,8 @@
#include <MG_Backend/BackendObject.h>
#include <MG_Pipe/MGPipe.h>
#include "Transport/SessionRings.h" // AbiFingerprintInputs / MixAbiFingerprint
namespace MobileGL::MG_Remote {
// ---- CallMask's layout (c0's ruling, extending R-8) ---------------------------------
@@ -90,10 +92,17 @@ namespace MobileGL::MG_Remote {
// ---- the ABI assertion the handshake carries ----------------------------------------
//
// Mixes sizeof(DynamicBackendParameters), sizeof(MGPCaps), sizeof(GLFunctionsTable) and
// the compile-time build fingerprint. Compared in Hello/Welcome; a mismatch is
// Fatal{AbiMismatch} and never a downgrade, because every alternative silently reads one
// struct as another.
// The inputs, as this build sees them: sizeof(DynamicBackendParameters), sizeof(MGPCaps),
// sizeof(GLFunctionsTable), the format-capability table's extents, the two caps-blob codec
// versions, MGPWireOp::kOpCount, the protocol ABI version and the compile-time git stamp.
// Public so that the sensitivity control can pin every one of them to the real value AND
// perturb them one at a time through the same mixer the handshake uses.
Transport::AbiFingerprintInputs CapsAbiFingerprintInputs();
// What Hello/Welcome carry and compare: EXACTLY Transport::MixAbiFingerprint(
// CapsAbiFingerprintInputs()) - one implementation, no second hash (ID-46 finding 6). A
// mismatch is Fatal{AbiMismatch} and never a downgrade, because every alternative silently
// reads one struct as another.
Uint64 CapsAbiFingerprint();
} // namespace MobileGL::MG_Remote
+29 -1
View File
@@ -25,6 +25,7 @@
#include <atomic>
#include <cstdlib>
#include <cstring>
#include <utility>
#include <vector>
namespace MobileGL::MG_Remote::Client {
@@ -341,7 +342,25 @@ namespace MobileGL::MG_Remote::Client {
// ---- 1. the control plane and the two bells. The transport owns the bells; THE
// SESSION owns the rings, and the accessors stay off ITransport (contract §3.9).
Transport::InProcessTransport::CreatePair(m_clientTransport, m_serverTransport);
std::unique_ptr<Transport::InProcessTransport> clientEnd;
std::unique_ptr<Transport::InProcessTransport> serverEnd;
Transport::InProcessTransport::CreatePair(clientEnd, serverEnd);
return StartOverTransportPair(std::move(clientEnd), std::move(serverEnd));
}
MobileGLResult ClientSession::StartOverTransportPair(
std::unique_ptr<Transport::InProcessTransport> clientEnd,
std::unique_ptr<Transport::InProcessTransport> serverEnd) {
if (m_started) {
return MOBILEGL_ERR_INVALID_ARGUMENT;
}
if (clientEnd == nullptr || serverEnd == nullptr) {
MGLOG_E("MG_Remote client: StartOverTransportPair needs both ends of one "
"InProcessTransport::CreatePair");
return MOBILEGL_ERR_INVALID_ARGUMENT;
}
m_clientTransport = std::move(clientEnd);
m_serverTransport = std::move(serverEnd);
m_transport = m_clientTransport.get();
// ---- 2. Hello. Sent before the server accepts: InProcessTransport queues whole
@@ -834,6 +853,15 @@ namespace MobileGL::MG_Remote::Client {
Uint32 ClientSession::MaxReplyBytes() const { return m_replies.MaxReplyBytes(); }
Bool ClientSession::ReplyCanHold(Uint64 bytes) const { return m_replies.CanHold(bytes); }
// ID-47. Forwarded verbatim so that the message, the boundary and the abort are the pool's
// and are pinned once, in SessionTest, rather than re-derived per caller.
void ClientSession::RequireReadPixelsReplyFits(Uint32 width, Uint32 height, Uint32 format,
Uint32 type, Uint64 bytes) const {
m_replies.RequireReadPixelsFits(width, height, format, type, bytes);
}
Transport::EventRingConsumer& ClientSession::Events() { return m_events; }
Transport::RingControl* ClientSession::Control() { return m_shm.CmdControl(); }
+25
View File
@@ -69,6 +69,19 @@ namespace MobileGL::MG_Remote::Client {
// ran monolith and went green" failure, and it must be loud.
MobileGLResult Start(MG_Config::TransportMode mode, const String& endpoint);
// Start()'s second half: the handshake and everything after it, over a transport pair
// the CALLER made with InProcessTransport::CreatePair. Start() refuses every mode but
// `inproc`, makes the pair, and calls this; it is public for exactly one reason. The
// Welcome guard below (envelope->msg_as_Welcome() == nullptr, ID-46 finding 7) can only
// be reached by a frame that arrives on the server->client direction BEFORE the
// server's own Welcome, and Start() builds that pair itself, so no control could put
// one there. SessionHandshakeTest does it through here, and the null-union Welcome must
// come back as MOBILEGL_ERR_PROTOCOL_MISMATCH with the guard's own line. Not a second
// way to start a session: MG_Backend::Init() calls Start(), and nothing else may call
// this with a pair it did not just create.
MobileGLResult StartOverTransportPair(std::unique_ptr<Transport::InProcessTransport> clientEnd,
std::unique_ptr<Transport::InProcessTransport> serverEnd);
// Teardown order matters and is table 3's fourth column: publish and let the server
// drain, Doorbell::Kill() (the ONLY thing that wakes an apply thread parked on
// kWaitForever, Doorbell.h:211-221), then join, and only then release anything an
@@ -157,6 +170,18 @@ namespace MobileGL::MG_Remote::Client {
// What one answer may carry. A ReadPixels bigger than this is Fatal rather than
// chunked, so the client checks BEFORE it emits.
Uint32 MaxReplyBytes() const;
// ID-47, the fifth primitive: true exactly when an answer of `bytes` can be posted.
Bool ReplyCanHold(Uint64 bytes) const;
// ID-47's named refusal, forwarded verbatim to ReplySlotPool::RequireReadPixelsFits.
// Returns when the answer fits; otherwise
// Fatal{ReplyTooLarge, "ReadPixels <w>x<h> <format> <bytes> > <cap>"}
// and abort - AT THE CLIENT, BEFORE EMISSION. Package c1's OnReadPixels emitter calls
// this once, immediately before EmitAndWait(MGPWireOp::ReadPixels, ...), with the
// record's box, its Format/Type enums and the DstSize it computed; the server's Post
// keeps its own refusal as the last line of defence, but that one fires on the apply
// thread with the record already on the wire, where all the client sees is a hang.
void RequireReadPixelsReplyFits(Uint32 width, Uint32 height, Uint32 format, Uint32 type,
Uint64 bytes) const;
// The reverse channel's reading end: OnBufferWriteback / OnGpuWritten /
// OnSurfaceChanged. Drained by the GL thread between verbs.
+14 -29
View File
@@ -265,20 +265,6 @@ namespace MobileGL::MG_Remote::Client {
ReadbackBytesPerPixel(format, type);
}
// ID-47's refusal, in its own function so the boundary pair can drive it without a
// session. See EmitTables.h.
void RefuseOversizeReadback(GLsizei width, GLsizei height, GLenum format, Uint64 bytes,
Uint64 capacity) {
if (bytes <= capacity) return;
MGLOG_F("MGPipe: Fatal{ReplyTooLarge, \"ReadPixels %dx%d 0x%04x %llu > %llu\"} - P5 "
"does not chunk a readback (R-10) and must not truncate one; grow "
"MOBILEGL_IPC_REPLY_MB or read less",
static_cast<int>(width), static_cast<int>(height),
static_cast<unsigned>(format), static_cast<unsigned long long>(bytes),
static_cast<unsigned long long>(capacity));
std::abort();
}
void EmitReadPixels(GLint x, GLint y, GLsizei width, GLsizei height, GLenum format,
GLenum type, void* pixels) {
ClientSession& session = RequireSession("ReadPixels");
@@ -305,13 +291,20 @@ namespace MobileGL::MG_Remote::Client {
info.DstOffset = 0;
info.DstSize = tight;
// CHECKED BEFORE THE EMISSION, not after the answer (ID-47). A reply bigger than a
// slot is Fatal on the SERVER, and a Fatal there is a dead apply thread with a
// client parked in the barrier for ever, naming a byte count and not a read; here
// it is one line naming the read. The capacity is read LIVE from the pool rather
// than compared against a constant, so s1's growth of SEG_REPLY to 16 MiB / eight
// 2 MiB slots needs no edit in this file.
RefuseOversizeReadback(width, height, format, tight, session.MaxReplyBytes());
// CHECKED BEFORE THE EMISSION, not after the answer (ID-47), and through S1's
// HELPER rather than a copy of it here. A reply bigger than a slot is Fatal on the
// SERVER too, and s1 keeps that as the last line of defence - but it fires on the
// apply thread with the record already on the wire, where all the client sees is a
// hang. This one names the read, at the call site that knows what the read was.
//
// THE NUMBER IS ID-49's TIGHT EXTENT and not the packed one: the pack state never
// crosses, so the answer that has to fit a slot is w*h*bytesPerPixel. The cap is
// the pool's own, read live, so s1's growth of SEG_REPLY to 16 MiB / eight 2 MiB
// slots needed no edit in this file - only the merge.
session.RequireReadPixelsReplyFits(static_cast<Uint32>(width),
static_cast<Uint32>(height),
static_cast<Uint32>(format),
static_cast<Uint32>(type), tight);
PixelStoreParameters pack{};
if (MG_State::pGLContext != nullptr) {
@@ -588,14 +581,6 @@ namespace MobileGL::MG_Remote::Client {
void SetDropClearEmissionForNegativeControl(Bool drop) { g_dropClearEmission = drop; }
Uint64 DroppedClearEmissions() { return g_droppedClearEmissions; }
// ID-47's refusal, exported so the boundary pair drives THE EMITTER'S OWN decision rather
// than a copy of it. One line, because the arithmetic that produced `bytes` is
// TightReadbackBytes' and the capacity is the pool's - this function only decides.
void RefuseReadbackLargerThanTheReplySlot(GLsizei width, GLsizei height, GLenum format,
Uint64 bytes, Uint64 capacity) {
RefuseOversizeReadback(width, height, format, bytes, capacity);
}
Bool ReadbackPackStateIsTightForTest(GLsizei width, Uint64 bytesPerPixel,
const PixelStoreParameters& pack) {
return ReadbackPackStateIsTight(width, bytesPerPixel, pack);
+7 -12
View File
@@ -117,18 +117,13 @@ namespace MobileGL::MG_Remote::Client {
Uint64 DroppedClearEmissions();
// ID-47. The CLIENT refuses a readback whose answer would not fit a reply slot, BEFORE it
// emits the record, and names the read. Never truncated (a short write is a silently
// truncated picture, the one failure an SSIM comparison cannot see) and never left to the
// server's `Post` abort (which happens on the apply thread, after the client is already
// parked in the barrier, and names a byte count rather than a read).
//
// IT IS A FREE FUNCTION SO THE BOUNDARY PAIR CAN DRIVE IT. `EmitReadPixels` needs a live
// session before it reaches any of this, so a control over the emitter could only ever
// observe Fatal{NoClientSession}; a control over THIS observes the decision and its exact
// message, and it is the same function the emitter calls rather than a second copy of the
// arithmetic. Returns when the read fits; aborts when it does not.
void RefuseReadbackLargerThanTheReplySlot(GLsizei width, GLsizei height, GLenum format,
Uint64 bytes, Uint64 capacity);
// emits the record, and names the read. THE REFUSAL ITSELF IS s1's -
// ClientSession::RequireReadPixelsReplyFits, forwarding to
// ReplySlotPool::RequireReadPixelsFits - and this package does not own a second copy of the
// message: two spellings of one refusal is how the two sides come to disagree about which
// reads are legal. What c1 owns is the CALL SITE and the number it passes, which is ID-49's
// tight extent; the control below is over that, and s1-v3.md §1's cases are over the
// helper.
// ID-49. `MGPReadbackInfo::DstSize` is the TIGHT w*h*bytesPerPixel extent - the reply
// payload - and nothing about the application's pack state crosses the wire. The server
+70 -15
View File
@@ -28,10 +28,13 @@
// uploads from the other side, and a client that folds it into OK accepts a
// pointer the server never handed out.
//
// A REPLY LARGER THAN ONE SLOT IS FATAL, NOT CHUNKED. P5's only large answer is
// ReadPixels, and the client knows its size before it emits the record, so an
// overflow means the two sides disagree about the frame rather than that the
// pool is too small. Chunking is P8's; growing the pool is an operator's.
// A REPLY LARGER THAN ONE SLOT IS FATAL, NOT CHUNKED, AND THE CLIENT SAYS SO
// FIRST (ID-47). P5's only large answer is ReadPixels, and the client knows its
// size before it emits the record, so it refuses an oversize read BY NAME before
// emission (RequireReadPixelsFits: Fatal{ReplyTooLarge, "ReadPixels <w>x<h>
// <format> <bytes> > <cap>"}); Post's own refusal is the server's last line of
// defence, and reaching it means the two sides disagree about the frame rather
// than that the pool is too small. Chunking is a P6 debt; the pool has no knob.
//
// ORDERING. The client only looks at a slot after it has seen
// RingControl::appliedSeq >= its own seq with an ACQUIRE load, and the server
@@ -77,15 +80,28 @@ namespace MobileGL::MG_Remote::Transport {
kReplyStatusError = 2,
};
// Eight slots of a 8 MiB SEG_REPLY is 1 MiB per answer.
// Eight slots of a 16 MiB SEG_REPLY is 2 MiB per slot, and MaxReplyBytes() is
// 2 MiB minus the 16-byte header = 2,097,136 bytes per answer (ID-47).
//
// Why eight and not sixty-four: while the verb barrier holds (R-1) the client
// blocks at every verb boundary, so the in-flight depth is exactly ONE and
// every extra slot buys nothing but a smaller maximum answer. The trade is
// the other way round - fewer slots, bigger replies - and P5's only large
// answer is a blocking ReadPixels. 1 MiB covers a 512x512 RGBA8 read. P9,
// which is what makes the pool asynchronous, re-chooses this geometry with
// real depth to size it against.
// answer is a blocking ReadPixels. P9, which is what makes the pool
// asynchronous, re-chooses this geometry with real depth to size it against.
//
// WHY 2 MiB AND NOT 1. The first version of this file said "1 MiB covers a
// 512x512 RGBA8 read", and it did not: 512*512*4 is exactly 1 MiB and the
// slot header takes 16 of those bytes, so Post refused it by sixteen. Worse,
// the E2 retrace harness's snapshot is a full-surface GL_RGBA/GL_UNSIGNED_BYTE
// read of OpenRA's 640x480 surface = 1,228,800 bytes through the interposer,
// 17% over the old cap - Post would have aborted on the first snapshot the
// day the client's ReadPixels emitter landed. Contract §2 row 23 says the
// slot is "sized from the scenario's largest read rather than guessed";
// 2 MiB is that size for every P5 exit-gate read (E2 is the largest at
// 640x480). A 2400x1080 RGBA8 device surface is ~10.4 MB and is a P6 debt
// (chunked readback or a dedicated readback carrier), recorded in the
// ROADMAP by the integrator - the geometry here is not the place it is paid.
inline constexpr std::uint32_t kDefaultReplySlotCount = 8;
// Slot 0 exists and is used: seq is 1-based, so seq % slotCount hits slot 0
@@ -152,6 +168,44 @@ namespace MobileGL::MG_Remote::Transport {
: m_slotBytes - static_cast<std::uint32_t>(sizeof(ReplySlotHeader));
}
// ID-47: THE CLIENT'S HALF of "a reply larger than one slot is fatal". True
// exactly when an answer of `bytes` can be posted into this pool: the pool
// is configured and `bytes <= MaxReplyBytes()`. The boundary is inclusive
// and SessionTest pins it from both sides.
bool CanHold(std::uint64_t bytes) const { return m_base != nullptr && bytes <= MaxReplyBytes(); }
// ID-47's named refusal, AT THE CLIENT, BEFORE EMISSION. Returns when the
// answer fits; otherwise
// Fatal{ReplyTooLarge, "ReadPixels <w>x<h> <format> <bytes> > <cap>"}
// and abort. Never truncated, never chunked, and never a server-side
// abort the client cannot name: Post's own refusal below stays as the
// last line of defence, but it fires on the apply thread with the record
// already on the wire, where the only thing the client sees is a hang.
//
// `format`/`type` are the GL enums the record carries (MGPReadbackInfo::
// Format/Type), printed as the hex pair every pipe diagnostic uses; the
// caller passes the byte count it computed for the record's DstSize, so
// what is refused is exactly what would have been posted. This is the one
// call c1's OnReadPixels emitter makes before EmitAndWait; the
// ClientSession forwards it verbatim (RequireReadPixelsReplyFits).
void RequireReadPixelsFits(std::uint32_t width, std::uint32_t height, std::uint32_t format,
std::uint32_t type, std::uint64_t bytes) const {
if (CanHold(bytes)) {
return;
}
WireLogFatal("MGPipe: Fatal{ReplyTooLarge, \"ReadPixels %ux%u 0x%04X/0x%04X %llu > %u\"} - "
"the answer does not fit one SEG_REPLY slot (%u slots of %u bytes, payload "
"cap %u; a cap of 0 means no reply pool is configured). Refused at the "
"client before emission (ID-47): P5 neither truncates nor chunks a reply, "
"and a read larger than the cap is a P6 debt (chunked readback), not a "
"bigger pool",
static_cast<unsigned>(width), static_cast<unsigned>(height),
static_cast<unsigned>(format), static_cast<unsigned>(type),
static_cast<unsigned long long>(bytes),
static_cast<unsigned>(MaxReplyBytes()), static_cast<unsigned>(m_slots),
static_cast<unsigned>(m_slotBytes), static_cast<unsigned>(MaxReplyBytes()));
}
// Zeroes every header, so a stale seq from a previous session cannot be
// mistaken for this session's answer. Called on the server side at Accept.
void Clear() {
@@ -170,24 +224,25 @@ namespace MobileGL::MG_Remote::Transport {
// chunked - see the file header.
void Post(std::uint64_t seq, std::int32_t status, const void* bytes, std::uint64_t size) {
if (m_base == nullptr) {
WireLogError("MG_Remote reply pool: Post(seq=%llu) on an unconfigured pool",
WireLogFatal("MG_Remote reply pool: Fatal{ProtocolCorruption} - Post(seq=%llu) on an "
"unconfigured pool",
static_cast<unsigned long long>(seq));
std::abort();
}
if (seq == 0) {
WireLogError("MG_Remote reply pool: seq 0 is \"no record\" and can never name a "
"slot (R-3: seq is 1-based)");
std::abort();
WireLogFatal("MG_Remote reply pool: Fatal{ProtocolCorruption} - seq 0 is \"no "
"record\" and can never name a slot (R-3: seq is 1-based)");
}
if (size > MaxReplyBytes()) {
WireLogError("MG_Remote reply pool: Fatal{ProtocolCorruption} - a %llu byte answer "
// The server's LAST line of defence, not the first: the client refuses an
// oversize ReadPixels by name before it emits (RequireReadPixelsFits, ID-47),
// so reaching this means the two sides disagree about the frame.
WireLogFatal("MG_Remote reply pool: Fatal{ProtocolCorruption} - a %llu byte answer "
"for seq %llu does not fit a %u byte slot (payload cap %u). P5 does "
"not chunk replies: the client knows an answer's size before it emits "
"the record, so this means the two sides disagree about the frame",
static_cast<unsigned long long>(size),
static_cast<unsigned long long>(seq), static_cast<unsigned>(m_slotBytes),
static_cast<unsigned>(MaxReplyBytes()));
std::abort();
}
std::uint8_t* slot = SlotAt(seq);
if (size != 0 && bytes != nullptr) {
+31 -20
View File
@@ -9,6 +9,7 @@
#include "Ring.h"
#include "SessionRings.h"
#include "WireLog.h"
#include <MG_Util/Debug/Log.h>
@@ -364,13 +365,15 @@ namespace MobileGL::MG_Remote::Transport {
const char* name) {
const std::uint64_t current = watermark.load(std::memory_order_relaxed);
if (to < current) {
MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"watermark\"} %s moved backwards, "
"%llu -> %llu. A waiter that already resumed on the higher value cannot "
"be un-resumed, and every later advance of this watermark would be a "
"no-op, so the verb barrier and every reply wait would block for ever",
name, static_cast<unsigned long long>(current),
static_cast<unsigned long long>(to));
std::abort();
// WireLogFatal, not MGLOG_F + abort: the line has to reach stderr
// for SessionTestDeath's control to name it (WireLog.h).
WireLogFatal("MGPipe: Fatal{ProtocolCorruption, \"watermark\"} %s moved backwards, "
"%llu -> %llu. A waiter that already resumed on the higher value "
"cannot be un-resumed, and every later advance of this watermark "
"would be a no-op, so the verb barrier and every reply wait would "
"block for ever",
name, static_cast<unsigned long long>(current),
static_cast<unsigned long long>(to));
}
if (to == current) {
return;
@@ -611,13 +614,12 @@ namespace MobileGL::MG_Remote::Transport {
// The ABI fingerprint's mixer
// -----------------------------------------------------------------------
std::uint64_t MixAbiFingerprint(std::uint64_t dynamicParamsSize, std::uint64_t capsSize,
std::uint64_t functionTableSize, std::uint32_t abiVersion,
const char* buildStamp) {
// FNV-1a over the four numbers and the stamp. Not a hash with any
// security property and not meant to be one: it has to (a) change when
// ANY input changes and (b) be computable identically in two processes
// built from one source tree, which rules out anything seeded at runtime.
std::uint64_t MixAbiFingerprint(const AbiFingerprintInputs& inputs) {
// FNV-1a over every field, in declaration order, then the stamp. Not a
// hash with any security property and not meant to be one: it has to
// (a) change when ANY input changes and (b) be computable identically in
// two processes built from one source tree, which rules out anything
// seeded at runtime.
std::uint64_t hash = 1469598103934665603ull;
const auto mix = [&hash](std::uint64_t value) {
for (int byte = 0; byte < 8; ++byte) {
@@ -625,12 +627,21 @@ namespace MobileGL::MG_Remote::Transport {
hash *= 1099511628211ull;
}
};
mix(dynamicParamsSize);
mix(capsSize);
mix(functionTableSize);
mix(abiVersion);
if (buildStamp != nullptr) {
for (const char* c = buildStamp; *c != '\0'; ++c) {
mix(inputs.DynamicParamsSize);
mix(inputs.CapsSize);
mix(inputs.FunctionTableSize);
mix(inputs.FormatCapabilityTargets);
mix(inputs.FormatCapabilityFormats);
mix(inputs.FormatCapabilitiesCodecVersion);
mix(inputs.RendererInfoCodecVersion);
mix(inputs.OpCount);
mix(inputs.AbiVersion);
// A presence marker before the bytes, so that "no stamp" (nullptr) and
// "an empty stamp" ("") are different inputs rather than the same
// absence of bytes.
mix(inputs.BuildStamp != nullptr ? 1u : 0u);
if (inputs.BuildStamp != nullptr) {
for (const char* c = inputs.BuildStamp; *c != '\0'; ++c) {
hash ^= static_cast<std::uint64_t>(static_cast<unsigned char>(*c));
hash *= 1099511628211ull;
}
+38
View File
@@ -34,6 +34,7 @@
#pragma once
#include <atomic>
#include <cstdint>
namespace MobileGL::MG_Remote::Transport {
@@ -55,6 +56,19 @@ namespace MobileGL::MG_Remote::Transport {
// from one that was not sampled.
std::uint64_t ProcessCurrentRssBytes();
// BOTH, FROM ONE PASS OVER /proc/self/status. The kernel does not keep
// hiwater_rss up to date on growth - it stores it only when RSS is about to
// DROP, and task_mem() reports max(stored hiwater, rss-at-this-read) - so
// two separate reads are not comparable: the fopen of the second read can
// itself grow RSS past the VmHWM the first read reported. That is exactly
// what GitHub run 35079459114 caught (VmHWM 4,784,128 < VmRSS 4,849,664 on
// ubuntu-24.04) and what ~/w7/p5-s1-probe reproduced locally: two reads
// disagree by 128 KiB with nothing allocated between them, one read never
// does (0/1000 with 64 KiB of growth per iteration). One pass is therefore
// the only way to read a pair, and even then the pair is a snapshot, not a
// bound - see SampleRoleMemoryInto.
void ProcessRssBytes(std::uint64_t* outPeakBytes, std::uint64_t* outCurrentBytes);
// The ledger. ShmSegment does NOT update it itself: a segment is also created
// by tests and by P6's adopt path, and a ledger that counted those would stop
// meaning "this session's footprint". The SESSION books its own segments.
@@ -68,6 +82,17 @@ namespace MobileGL::MG_Remote::Transport {
std::uint64_t LedgerMappedBytesAllRoles();
// One sample, both halves, for one role.
//
// PeakRssBytes IS THE LEDGER'S OWN RUNNING MAXIMUM, not the kernel's VmHWM.
// It is the largest of every kernel peak and every kernel current this
// process has folded in through SampleRoleMemoryInto, so it is >= this
// sample's CurrentRssBytes BY CONSTRUCTION and never decreases. The kernel's
// VmHWM feeds it (it is a lower bound on the true peak that this process's
// own samples may have missed) but is never reported on its own: as
// ProcessRssBytes explains, the kernel's peak is not a monotone bound on the
// kernel's current at read time, so a sample that reported VmHWM verbatim
// could show a peak below its own current - which is a number t1 cannot put
// in MEASUREMENTS and a test cannot assert against. GitHub run 35079459114.
struct RoleMemorySample {
std::uint64_t PeakRssBytes = 0;
std::uint64_t CurrentRssBytes = 0;
@@ -75,8 +100,21 @@ namespace MobileGL::MG_Remote::Transport {
MemoryRole Role = MemoryRole::Client;
};
// One pass over /proc/self/status folded into the PROCESS's running peak.
RoleMemorySample SampleRoleMemory(MemoryRole role);
// The fold itself, over a running peak the CALLER owns: `runningPeak`
// becomes max(runningPeak, kernelPeakRssBytes, kernelCurrentRssBytes) and
// the sample reports that as PeakRssBytes beside `kernelCurrentRssBytes`.
// SampleRoleMemory calls this with the process-wide running peak and the
// numbers it just read; SessionTest calls it with a running peak of its own
// and a stubbed reader whose current EXCEEDS its peak, which must not fail
// (R-16's control on this rule: revert PeakRssBytes to the kernel's peak and
// it does).
RoleMemorySample SampleRoleMemoryInto(std::atomic<std::uint64_t>& runningPeak, MemoryRole role,
std::uint64_t kernelPeakRssBytes,
std::uint64_t kernelCurrentRssBytes);
// Emits one line at INFO level, which is what every P5 lane builds at, so
// t1's harness can grep it out of a lane log without a new log sink. Not
// DEBUG, which the INFO build compiles out; not ERROR, which this is not.
+50 -13
View File
@@ -53,7 +53,8 @@
//
// SegmentRef.sizeBytes therefore announces the MAPPING size (ring + page), which
// is what a spawn peer must mmap. The four numbers a reader recognises - 8 MiB /
// 32 MiB / 8 MiB / 256 KiB - are the RING sizes, which is what the knobs name.
// 32 MiB / 16 MiB / 256 KiB (ID-47) - are the RING sizes, which is what the
// knobs name.
//
// SEG_STAGE has no control page of its own: RingControl carries TWO cursor
// triples (Ring.h:101-109) and the stage triple is the second. So SEG_STAGE is
@@ -86,8 +87,13 @@ namespace MobileGL::MG_Remote::Transport {
// two either. Rounding was a ring requirement and keeping it would have
// silently turned an operator's MOBILEGL_IPC_STAGE_MB=24 into 16.
std::uint64_t StageBytes = 32ull * 1024 * 1024;
std::uint64_t ReplyBytes = 8ull * 1024 * 1024; // slot pool, not a ring
std::uint64_t EventRingBytes = 256ull * 1024; // + one control page
// SEG_REPLY: a slot pool, not a ring, and NO KNOB MOVES IT (contract §5 has
// none). ID-47: 16 MiB = eight slots of 2 MiB, sized from the largest P5
// read - the E2 retrace's full-surface 640x480 RGBA8 snapshot, 1,228,800
// bytes - which the previous 8 MiB / 1 MiB-per-slot pool could not hold
// (ReplySlot.h says how it was found). ProtocolSmokeTest pins the number.
std::uint64_t ReplyBytes = 16ull * 1024 * 1024;
std::uint64_t EventRingBytes = 256ull * 1024; // + one control page
std::uint32_t ReplySlotCount = kDefaultReplySlotCount;
};
@@ -473,17 +479,48 @@ namespace MobileGL::MG_Remote::Transport {
};
// -----------------------------------------------------------------------
// The ABI fingerprint's mixer.
// The ABI fingerprint's mixer - THE ONE IMPLEMENTATION.
//
// It lives under Transport/ rather than in CapsCodec.cpp so that it can be
// tested without the GL frontend's umbrella header, and so that the SIZES it
// mixes are the caller's - CapsCodec.cpp passes the three real sizeofs, a
// unit test passes made-up ones and can then prove a one-byte difference
// changes the answer. A fingerprint that cannot be shown to change is
// indistinguishable from one that is never compared.
// CapsCodec.cpp's CapsAbiFingerprint(), the value both handshakes compare,
// is exactly MixAbiFingerprint(CapsAbiFingerprintInputs()). It lives under
// Transport/ so that it can be tested without the GL frontend's umbrella
// header, and it takes its inputs as a struct so that the SAME function the
// handshake calls can be driven with one field perturbed at a time.
//
// The wave-1 review (ID-46 finding 6) found the previous shape - a
// five-argument mixer here and a SEPARATE hand-rolled FNV loop in
// CapsCodec.cpp - had exactly one caller of this function: the sensitivity
// test. Production never called it, so replacing CapsAbiFingerprint() with
// `return 1;` left every fingerprint test green and both peers agreeing on
// nothing. Now there is one mixer, the sensitivity case starts from the
// production entry point, and that perturbation turns it red.
// -----------------------------------------------------------------------
std::uint64_t MixAbiFingerprint(std::uint64_t dynamicParamsSize, std::uint64_t capsSize,
std::uint64_t functionTableSize, std::uint32_t abiVersion,
const char* buildStamp);
struct AbiFingerprintInputs {
// The three struct shapes table 0's ABI-agreement row names.
std::uint64_t DynamicParamsSize = 0;
std::uint64_t CapsSize = 0;
std::uint64_t FunctionTableSize = 0;
// The caps blob's own geometry and the two blob codecs' versions: the
// format-capability table's extents and the codec version stamps.
std::uint64_t FormatCapabilityTargets = 0;
std::uint64_t FormatCapabilityFormats = 0;
std::uint64_t FormatCapabilitiesCodecVersion = 0;
std::uint64_t RendererInfoCodecVersion = 0;
// The catalogue's length (ID-33): a peer with one more opcode is a
// different wire even if every struct kept its size.
std::uint64_t OpCount = 0;
// MOBILEGL_ABI_VERSION(major, minor): a protocol change that left every
// struct the same size, which nothing above can see.
std::uint32_t AbiVersion = 0;
// GIT_COMMIT_HASH_SHORT: two builds of the same sizes can still disagree
// about a FIELD ORDER, which no sizeof can see. nullptr and "" are
// distinct inputs and neither equals a real stamp.
const char* BuildStamp = nullptr;
};
// FNV-1a over every field above, in declaration order. Never 0: that value is
// reserved for "not stated", so a peer that forgot to fill the field cannot
// accidentally agree with one that did.
std::uint64_t MixAbiFingerprint(const AbiFingerprintInputs& inputs);
} // namespace MobileGL::MG_Remote::Transport
+84 -34
View File
@@ -78,43 +78,73 @@ namespace MobileGL::MG_Remote::Transport {
return role == MemoryRole::Server ? "server" : "client";
}
// One pass over /proc/self/status for a "VmHWM:" / "VmRSS:" line. The
// values are in kB and the unit suffix is part of the line, so it is
// parsed rather than assumed.
std::uint64_t ProcStatusBytes(const char* key) {
#if defined(__linux__) || defined(__ANDROID__)
std::FILE* file = std::fopen("/proc/self/status", "re");
if (file == nullptr) {
return 0;
}
// "<key>:\t <number> kB" -> bytes. The unit suffix is part of the
// line, so it is parsed rather than assumed; anything else is a kernel
// this code has not seen, and 0 ("not measured") is the honest answer.
bool ParseKilobyteLine(const char* line, const char* key, std::uint64_t* outBytes) {
const std::size_t keyLength = std::strlen(key);
char line[256];
std::uint64_t bytes = 0;
while (std::fgets(line, sizeof(line), file) != nullptr) {
if (std::strncmp(line, key, keyLength) != 0) {
continue;
}
unsigned long long kilobytes = 0;
// The format is "<key>:\t <number> kB". Anything else is a
// kernel this code has not seen, and 0 ("not measured") is the
// honest answer for it.
if (std::sscanf(line + keyLength, ": %llu kB", &kilobytes) == 1) {
bytes = static_cast<std::uint64_t>(kilobytes) * 1024ull;
}
break;
if (std::strncmp(line, key, keyLength) != 0) {
return false;
}
std::fclose(file);
return bytes;
#else
(void)key;
return 0;
#endif
unsigned long long kilobytes = 0;
if (std::sscanf(line + keyLength, ": %llu kB", &kilobytes) == 1) {
*outBytes = static_cast<std::uint64_t>(kilobytes) * 1024ull;
}
return true;
}
// The process-wide running peak SampleRoleMemory folds into. One per
// process, like VmHWM itself; under inproc both roles share it and the
// log line says so.
std::atomic<std::uint64_t>& ProcessRunningPeak() {
static std::atomic<std::uint64_t> peak{0};
return peak;
}
} // namespace
std::uint64_t ProcessPeakRssBytes() { return ProcStatusBytes("VmHWM"); }
// ONE PASS FOR BOTH KEYS - see RoleMemory.h. A pass per key was the wave-1
// shape and it is what GitHub run 35079459114 caught: the second fopen grew
// RSS past the VmHWM the first pass had reported, because the kernel only
// stores hiwater_rss when RSS is about to drop and reports max(stored, now)
// otherwise, so the two reads were snapshots of different "now"s.
void ProcessRssBytes(std::uint64_t* outPeakBytes, std::uint64_t* outCurrentBytes) {
std::uint64_t peak = 0;
std::uint64_t current = 0;
#if defined(__linux__) || defined(__ANDROID__)
std::FILE* file = std::fopen("/proc/self/status", "re");
if (file != nullptr) {
char line[256];
bool sawPeak = false;
bool sawCurrent = false;
while ((!sawPeak || !sawCurrent) && std::fgets(line, sizeof(line), file) != nullptr) {
if (!sawPeak && ParseKilobyteLine(line, "VmHWM", &peak)) {
sawPeak = true;
} else if (!sawCurrent && ParseKilobyteLine(line, "VmRSS", &current)) {
sawCurrent = true;
}
}
std::fclose(file);
}
#endif
if (outPeakBytes != nullptr) {
*outPeakBytes = peak;
}
if (outCurrentBytes != nullptr) {
*outCurrentBytes = current;
}
}
std::uint64_t ProcessCurrentRssBytes() { return ProcStatusBytes("VmRSS"); }
std::uint64_t ProcessPeakRssBytes() {
std::uint64_t peak = 0;
ProcessRssBytes(&peak, nullptr);
return peak;
}
std::uint64_t ProcessCurrentRssBytes() {
std::uint64_t current = 0;
ProcessRssBytes(nullptr, &current);
return current;
}
void LedgerAddSegment(MemoryRole role, std::uint64_t bytes) {
LedgerSlot(role).fetch_add(bytes, std::memory_order_relaxed);
@@ -140,15 +170,35 @@ namespace MobileGL::MG_Remote::Transport {
return total;
}
RoleMemorySample SampleRoleMemory(MemoryRole role) {
RoleMemorySample SampleRoleMemoryInto(std::atomic<std::uint64_t>& runningPeak, MemoryRole role,
std::uint64_t kernelPeakRssBytes,
std::uint64_t kernelCurrentRssBytes) {
// max(running, kernel peak, kernel current), as a CAS loop: two threads
// sampling at once (the GL thread and the apply thread both log memory
// at phase boundaries) must not let a lower value overwrite a higher.
const std::uint64_t observed =
kernelPeakRssBytes > kernelCurrentRssBytes ? kernelPeakRssBytes : kernelCurrentRssBytes;
std::uint64_t peak = runningPeak.load(std::memory_order_relaxed);
while (observed > peak &&
!runningPeak.compare_exchange_weak(peak, observed, std::memory_order_relaxed)) {
}
RoleMemorySample sample;
sample.Role = role;
sample.PeakRssBytes = ProcessPeakRssBytes();
sample.CurrentRssBytes = ProcessCurrentRssBytes();
// THE RUNNING MAXIMUM, never the kernel's peak verbatim - RoleMemory.h
// says why, and SessionTest's stubbed-reader control is the gate on it.
sample.PeakRssBytes = observed > peak ? observed : peak;
sample.CurrentRssBytes = kernelCurrentRssBytes;
sample.MappedSegmentBytes = LedgerMappedBytes(role);
return sample;
}
RoleMemorySample SampleRoleMemory(MemoryRole role) {
std::uint64_t kernelPeak = 0;
std::uint64_t kernelCurrent = 0;
ProcessRssBytes(&kernelPeak, &kernelCurrent);
return SampleRoleMemoryInto(ProcessRunningPeak(), role, kernelPeak, kernelCurrent);
}
void LogRoleMemory(const char* phase, const RoleMemorySample& sample) {
// INFO, not DEBUG: t1 greps this out of a lane log and MGLOG_D is compiled out at the
// INFO level every P5 lane builds at. It is a handful of lines per session - the
+20
View File
@@ -12,6 +12,7 @@
#include <cstdarg>
#include <cstdio>
#include <cstdlib>
namespace MobileGL::MG_Remote::Transport {
@@ -30,4 +31,23 @@ namespace MobileGL::MG_Remote::Transport {
MGLOG_E("%s", line);
}
void WireLogFatal(const char* format, ...) {
char line[512];
va_list args;
va_start(args, format);
const int written = std::vsnprintf(line, sizeof(line), format, args);
va_end(args);
if (written < 0) {
std::snprintf(line, sizeof(line),
"MG_Remote wire: unformattable Fatal diagnostic (format=%s)", format);
}
MGLOG_F("%s", line);
// The stderr echo is the half a death test can see (WireLog.h). Unbuffered
// by default and flushed anyway: abort() does not flush stdio.
std::fputs(line, stderr);
std::fputc('\n', stderr);
std::fflush(stderr);
std::abort();
}
} // namespace MobileGL::MG_Remote::Transport
+24 -3
View File
@@ -19,9 +19,9 @@
// the one header under Transport/ that broke the rule; it now calls this
// instead, and the umbrella stays inside WireLog.cpp.
//
// ERROR only, deliberately. Everything routed here is a latched protocol
// violation, never per-frame noise; non-critical wire lines use MGLOG_D from a
// .cpp, where the INFO build compiles them out entirely.
// ERROR and FATAL only, deliberately. Everything routed here is a latched
// protocol violation, never per-frame noise; non-critical wire lines use
// MGLOG_D from a .cpp, where the INFO build compiles them out entirely.
#pragma once
@@ -35,4 +35,25 @@ namespace MobileGL::MG_Remote::Transport {
void
WireLogError(const char* format, ...);
// Formats one line, emits it at FATAL level (MGLOG_F), ECHOES IT TO STDERR,
// and aborts. Every `Fatal{...}` this layer raises goes through here.
//
// WHY STDERR AS WELL. Defines.h builds the logger with the console sink OFF
// and the file sink ON, so an MGLOG line reaches exactly one place: the log
// file. A Fatal is the last thing the process says, and three readers need
// it somewhere other than a file whose path they may not know: a terminal,
// a CI job log, and a gtest death test - which matches its regex against
// the CHILD'S STDERR and nothing else. The wave-1 review (ID-46 finding 10)
// found both SessionTestDeath controls written with an empty regex for
// precisely that reason: with the diagnostic reachable only through the
// file sink there was nothing on stderr to name, so a bare std::abort() or
// a segfault satisfied them. The echo is what lets a death control name
// its diagnostic (R-16: a negative control asserts its own failure reason).
[[noreturn]]
#if defined(__GNUC__) || defined(__clang__)
__attribute__((format(printf, 1, 2)))
#endif
void
WireLogFatal(const char* format, ...);
} // namespace MobileGL::MG_Remote::Transport
+93 -1
View File
@@ -31,6 +31,9 @@
#include <Config.h>
#include <MG_Pipe/MGPipeRenderStateSpans.h>
#include <MG_Pipe/PipeApply.h>
// R-6's tier gate, and the ONE spelling of it (b1's file, unchanged by this package): the
// MapPersistent arm below asks it the same question MGPipeApplyMapPersistent asks.
#include <MG_Remote/Client/PersistentMapTracker.h>
#include <MG_Remote/Protocol/generated/protocol_generated.h>
#include <MG_State/GLState/ProgramState/ProgramArtifactsCodec.h>
#include <MG_Util/Debug/Log.h>
@@ -457,6 +460,36 @@ namespace MobileGL::MG_Remote::Wire {
WireOpName(op), static_cast<unsigned long long>(blob.Size));
std::abort();
}
// R-2.3's SECOND HALF: "inside SOME segment" IS NOT THE RULE. Contract table 1 gives
// every client->server content blob - groups A, B and C, all nineteen rows - the ONE
// carrier SEG_STAGE, and R-10 sends blobs there whole. Until this arm existed the only
// test was that the run resolved, so `CreateSamplerState.Parameters={Seg=SEG_REPLY,...}`
// was accepted and APPLIED: a server-owned segment, whose reuse is the reply pool's
// business and has nothing to do with stage retirement, carrying bytes the applier
// then read. It also went unpoisoned - NoteResolvedRun skipped every non-stage carrier
// - so rule C's only mechanical control read zero on exactly the record that needed it.
//
// The segment is checked BEFORE the resolve, deliberately: a forged SEG_REPLY run that
// happens to lie inside a mapped reply pool must be refused for naming the wrong
// carrier, not left to pass or fail on whether that pool is mapped at all.
//
// NOT A NEW FATAL FAMILY. The review suggested `Fatal{BlobNotStaged}`; this is
// ProtocolCorruption like every other R-2 honesty arm, because the families are the
// vocabulary the operator and the CI greps share (ProtocolCorruption, AbiMismatch,
// UnmigratedVerb, UnmigratedPipeInput, UnsetCallMask, RingOverrun) and a one-off
// seventh name would be a token nothing else in the tree recognises. The SEGMENT is in
// the message, which is what has to be greppable.
if (blob.Seg != kSegStage) {
MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"%s.blob\"} seg=%u offset=%llu "
"size=%llu is not SEG_STAGE(%u); every client->server content blob is "
"staged whole in SEG_STAGE (contract table 1 groups A/B/C, R-10) and no "
"other segment may carry one",
WireOpName(op), static_cast<unsigned>(blob.Seg),
static_cast<unsigned long long>(blob.Offset),
static_cast<unsigned long long>(blob.Size),
static_cast<unsigned>(kSegStage));
std::abort();
}
if (segments.Resolve(blob.Seg, blob.Offset, blob.Size) == nullptr) {
MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"%s.blob\"} seg=%u offset=%llu size=%llu "
"does not lie inside that segment (R-2.3)",
@@ -717,6 +750,12 @@ namespace MobileGL::MG_Remote::Wire {
}
for (int attempt = 0; attempt < 2; ++attempt) {
// THE WRAP SKIP MAY ONLY BE CHARGED AGAINST BYTES THAT ARE STILL IN FLIGHT. When
// there are none the allocator starts over at offset zero, so a blob the segment
// can hold whole is never refused (see RebaseEmptyStage). On attempt 1 this runs
// AFTER ReclaimStagedBytes, which is the case the finding describes: 8 MiB
// allocated, then retired, then a 28 MiB request that used to abort.
RebaseEmptyStage();
const Uint64 offset = m_stageHead % m_stageCapacity;
// A run is always contiguous: one that would straddle the end skips the remainder,
// exactly as the ring's wrap pad does, and the skipped bytes are reclaimed with
@@ -936,6 +975,32 @@ namespace MobileGL::MG_Remote::Wire {
return m_emitSeq;
}
// THE EMPTY-STAGE REBASE. Head and tail are monotonic byte counts, so "every staged byte
// has retired" reads head == tail, NOT head == tail == 0, and `head % capacity` is left
// wherever the last run ended. Charging a wrap skip against that offset then costs the
// unused suffix a second time: with head == tail == 64 in a 256 KiB stage, the allocator's
// test became `2*capacity - 64 <= capacity`, which is false at EVERY occupancy, so a blob
// that fits the segment whole was refused with `Fatal{RingOverrun, "SEG_STAGE"}` - whose
// own message then reported `0 bytes still in flight`. ReclaimStagedBytes cannot help,
// because an already-empty tail has nothing left to move.
//
// THE MARK QUEUE COMES WITH IT. A mark holds the ABSOLUTE head cursor it was pushed at and
// ReclaimStagedBytes assigns that value straight to m_stageTail. Every mark not yet
// consumed has StageCursor <= head == tail - the head is monotonic and marks are pushed in
// order - so each of them names a region that is already reclaimed and zero is the
// truthful rebasing of it. Without that, one reclaim after a rebase would put the tail
// AHEAD of the head and StagedBytesInFlight() would underflow to about 2^64.
void PipeWireEncoder::RebaseEmptyStage() {
if (m_stageHead != m_stageTail || m_stageHead == 0) {
return;
}
m_stageHead = 0;
m_stageTail = 0;
for (SizeT i = 0; i < m_stageMarks.size(); ++i) {
m_stageMarks[i].StageCursor = 0;
}
}
void PipeWireEncoder::ReclaimStagedBytes() {
if (m_control == nullptr) {
return;
@@ -1083,8 +1148,22 @@ namespace MobileGL::MG_Remote::Wire {
}
void PipeWireDecoder::NoteResolvedRun(MGPWireOp op, const MGPBlobRef& blob) {
// UNREACHABLE NOW, AND LOUD RATHER THAN SILENT. This used to `return`, and that made
// the audit's bookkeeping quietly optional: a record naming a non-SEG_STAGE carrier
// was applied AND recorded nothing, so PoisonedStageBytes() stayed zero and rule C's
// only mechanical control was dark on exactly the record it existed to catch. The one
// caller is ResolveOrFatal, which runs RequireDeclaredBlob first, and that now refuses
// both an undeclared blob and a non-SEG_STAGE one by name. If either ever arrives here
// the audit has stopped covering the carrier, which is the same failure as no audit at
// all - the reason the run-count overflow just below is a Fatal too.
if (blob.Size == 0 || blob.Seg != kSegStage) {
return;
MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"%s\"} the audit was asked to record a "
"resolved run with seg=%u size=%llu; only declared SEG_STAGE(%u) runs "
"reach the poison fill (R-2.5)",
WireOpName(op), static_cast<unsigned>(blob.Seg),
static_cast<unsigned long long>(blob.Size),
static_cast<unsigned>(kSegStage));
std::abort();
}
if (m_resolvedCount >= sizeof(m_resolved) / sizeof(m_resolved[0])) {
// LOUD, NOT A SILENT DROP. This array is what the 0xDD fill covers, and a poison
@@ -1323,6 +1402,19 @@ namespace MobileGL::MG_Remote::Wire {
//
// DECLINED is a real answer, not a failure: the three frontend sites already
// tolerate it (BufferObject.cpp:238, :603-606, :657-660).
//
// THE TIER IS CONSULTED HERE, AND IT IS THE SAME CONJUNCTION THE MONOLITH APPLIER
// USES (PipeApply.cpp's `Transport != Monolith && AdoptTierIsEmulate()`). The arm
// used to decline UNCONDITIONALLY and AdoptTier had no reference anywhere on the
// codec path, so MOBILEGL_IPC_ADOPT_TIER=0 and =1 - which contract §5 promises
// "parse and are Fatal at use, naming P11" - decoded as an ordinary DECLINED and
// the operator got a run that looked like a working T0. AdoptTierIsEmulate returns
// true at T2 and ABORTS at T0/T1 on its own named diagnostic, so the return value
// is deliberately not a branch: P5 declines at every tier it survives (R-6), and
// the two forbidden ones never get this far.
if (MG_Config::Transport != MG_Config::TransportMode::Monolith) {
(void)MG_Remote::Client::AdoptTierIsEmulate();
}
PostReply(op, seq, ReplySink::kStatusDeclined, nullptr, 0);
return true;
+11
View File
@@ -290,6 +290,17 @@ namespace MobileGL::MG_Remote::Wire {
// RingControl - see ReclaimStagedBytes above.
Uint8* StageAllocate(Uint64 size);
// AN EMPTY STAGE STARTS OVER AT ZERO, so that the wrap skip is only ever charged
// against bytes that are really still in flight. Head and tail are monotonic, so once
// everything has retired they are EQUAL BUT NOT ZERO, and `head % capacity` is
// wherever the last run happened to end - a wrap skip charged against that offset
// costs the suffix a second time and refused a blob the whole segment could hold, with
// a message that reported zero bytes in flight while it did so. Rebasing also rewrites
// the marks still held: a mark stores an ABSOLUTE head cursor and a later reclaim
// assigns it to m_stageTail, so leaving a stale one behind would drive the tail past
// the head and underflow StagedBytesInFlight().
void RebaseEmptyStage();
Transport::RingControl* m_control = nullptr;
Transport::RingProducer* m_cmd = nullptr;
Transport::RingProducer* m_stage = nullptr;
+108 -1
View File
@@ -41,6 +41,21 @@ namespace {
using MG_Remote::Client::GpuWriteProducer;
using MG_Remote::Client::PersistentMapTracker;
// A backend that MINTS a persistent mapping, for the one case that needs the adopted arm
// (TheAdoptedArmIsNotAMemberAndItIsTheChainRowThatSaysSo). Only AcquirePersistentMap is
// filled in: the frontend null-checks every op individually, and a table with one live
// member is the smallest thing that makes AcquireMemoryRange's legacy adoption arm fire.
// The storage is the CASE's, not this table's - AdoptPersistentMap keeps the pointer and
// never owns it - so the base travels through a file-scope variable the case sets and
// clears around the one acquisition it wants minted.
void* g_mintedPersistentBase = nullptr;
void* MintPersistentMap(BufferObject&) { return g_mintedPersistentBase; }
const MG_State::GLState::BufferBackendOps g_mintingBufferOps = [] {
MG_State::GLState::BufferBackendOps ops{};
ops.AcquirePersistentMap = &MintPersistentMap;
return ops;
}();
// Everything in this package is gated on `Transport != Monolith`, so every case has to
// put the process into a split configuration and put it back. A fixture rather than a
// lambda because the tracker is a process-wide singleton and a case that left an entry in
@@ -64,6 +79,11 @@ namespace {
MG_Remote::Client::ResetProducerMarkCountsForTest();
}
void TearDown() override {
// Belt and braces for the one case that installs a minting backend: a table left
// behind would make the NEXT case's acquisition land in the adopted arm, and every
// membership assertion in this file would then be about a different code path.
MG_State::GLState::SetBufferBackendOps(nullptr);
g_mintedPersistentBase = nullptr;
PersistentMapTracker::Instance().ClearForTest();
MG_State::pGLContext = Move(m_context);
MG_Config::Transport = m_transport;
@@ -258,6 +278,92 @@ TEST_F(SplitBufferSet, MembershipIsSyncPersistentMappedRangesOwnEarlyOutChain) {
buffer->ReleaseMemory(false);
}
// THE ADOPTED ARM IS NOT A MEMBER, AND IT IS THE CHAIN THAT SAYS SO - NOT THE TIER (ID-42).
//
// IsLivePersistentMap's second row is `if (buffer.IsBackendPersistentMapped()) return false;`,
// and its comment promises that the row answers for ITSELF: at tier T2 the arm is unreachable
// because MapPersistent declines, but a build that reaches T0/T1 later must get the same answer
// out of the same row. Nothing drove that promise. The case above only ever sees the declined
// arm, and PersistentCoherentMapScenario's membership assertion cannot pin it either - an
// integration entry sees whichever arm its driver and transport hand it, which is exactly how
// that assertion came to state the emulated arm's property as if it were universal and go red on
// seven split-monolith entries the first time it ran.
//
// So the statement is pinned HERE, where both answers can be produced on demand. The adoption is
// made by the PRODUCTION path - AcquireMemoryRange dispatching to the backend's
// AcquirePersistentMap and calling PipeResource::AdoptPersistentMap - and not by the case
// reaching into the buffer, because a hand-set flag would still be "true" with the production
// adoption deleted (R-16). The transport is Monolith for exactly that call, because R-6's decline
// is ANDed with `Transport != Monolith` and there is no other way to reach a mint in this build;
// the PREDICATE is then asked with the tier back at T2/InProcess, which is the whole point: the
// tier says "emulated, always" and the chain still says "not a member".
TEST_F(SplitBufferSet, TheAdoptedArmIsNotAMemberAndItIsTheChainRowThatSaysSo) {
constexpr SizeT kSize = 4096;
// The storage the backend "mints". Declared first so it outlives the buffer: AdoptPersistentMap
// stores the pointer and releases the shadow, and it never owns what it was handed.
Vector<Uint8> minted(kSize, static_cast<Uint8>(0));
g_mintedPersistentBase = minted.data();
auto adopted = MakeBuffer(27u, kSize);
auto declined = MakeBuffer(28u, kSize);
// The declined twin first, with no backend ops at all: same flags, same size, same call.
declined->AcquireMemoryRange(Range1D{0, kSize},
BufferMappingAccessBit::Write | BufferMappingAccessBit::Persistent);
ASSERT_FALSE(declined->IsBackendPersistentMapped());
ASSERT_TRUE(PersistentMapTracker::IsLivePersistentMap(*declined))
<< "the emulated arm IS a member - if this fails the two halves are not comparable and the "
"assertion below proves nothing";
{
MG_State::GLState::SetBufferBackendOps(&g_mintingBufferOps);
MG_Config::Transport = MG_Config::TransportMode::Monolith;
adopted->AcquireMemoryRange(Range1D{0, kSize},
BufferMappingAccessBit::Write | BufferMappingAccessBit::Persistent);
MG_Config::Transport = MG_Config::TransportMode::InProcess;
MG_State::GLState::SetBufferBackendOps(nullptr);
}
ASSERT_TRUE(adopted->IsBackendPersistentMapped())
<< "the backend declined the mint, so there is no adopted arm here to ask about";
// THE TIER SAYS EMULATED. The chain must still say "not a member".
ASSERT_TRUE(MG_Remote::Client::AdoptTierIsEmulate());
ASSERT_NE(MG_Config::Transport, MG_Config::TransportMode::Monolith);
EXPECT_FALSE(PersistentMapTracker::IsLivePersistentMap(*adopted))
<< "an adopted store's bytes are already in host-visible coherent GPU memory and there is "
"nothing for the push to ship, so IsBackendPersistentMapped() takes it out of the set. "
"This answer must come from that ROW and not from the adopt tier: the tier is T2 and the "
"transport is InProcess right now, which is the configuration in which R-6 says every "
"acquisition declines - and the store in front of the predicate is adopted anyway, "
"because a later phase's T0/T1 will mint one. A predicate that read the tier would call "
"it a member and the push would read an adopted store's Bytes() as if it were the "
"shadow.";
// The SET agrees with the predicate, asked through the production entry point rather than by
// reading a member: NoteMapStateChanged is what every one of the five maintenance events
// calls, and it must refuse to enrol an adopted store.
PersistentMapTracker::Instance().NoteMapStateChanged(*adopted);
EXPECT_EQ(PersistentMapTracker::Instance().MemberCount(), 1u)
<< "only the declined twin: enrolling an adopted store would make the push read its "
"Bytes() - which is the GPU map, not the shadow - as if it were bytes to ship";
// ...and the consequence, which is the one that would actually corrupt something.
const Uint64 before = MG_Util::PipeStats::TotalBytes(MG_Util::PipeStats::ByteClass::PersistentMapPush);
MG_Remote::Client::PushPersistentMapsBeforeVerb();
EXPECT_EQ(MG_Util::PipeStats::TotalBytes(MG_Util::PipeStats::ByteClass::PersistentMapPush) - before,
static_cast<Uint64>(kSize))
<< "the declined twin's 4096 bytes and nothing else: the adopted buffer must contribute no "
"pushed bytes at all";
declined->ReleaseMemory(false);
// The adopted one is NOT released through ReleaseMemory: ReleasePersistentMap is for a store
// being redefined, and a persistent map the application holds outlives every unmap by
// definition (PipeResource.h:121-127). It is dropped here with the mapping still adopted,
// which is also the shape ~BufferObject has to survive.
adopted.reset();
g_mintedPersistentBase = nullptr;
}
// pmap is non-zero, and it is non-zero in BLOCKS.
TEST_F(SplitBufferSet, ThePushCutsTheMappedSpanIntoBlocksAndMovesPmap) {
constexpr SizeT kSize = 4u * 64u * 1024u; // exactly four 64 KiB blocks
@@ -402,7 +508,7 @@ TEST_F(SplitBufferSet, OnlyAdoptTierTwoIsImplemented) {
#else
// THE SAME FIFTEEN NAMES, SO THE ctest NAME SET DOES NOT MOVE BETWEEN LANES. G2 compares the
// THE SAME SIXTEEN NAMES, SO THE ctest NAME SET DOES NOT MOVE BETWEEN LANES. G2 compares the
// pull and push name lists line for line and G14 allows build-split to ADD names but never to
// remove one, so a case that exists only where it can run would break both gates for a reason
// that has nothing to do with what it tests. It skips instead, and says why.
@@ -417,6 +523,7 @@ TEST(SplitBufferSet, Row4AReadPixelsIntoAPackPboMarksThePbo) { MGL_SPLIT_ONLY_OR
TEST(SplitBufferSet, Row5EndTransformFeedbackMarksTheCaptureTargets) { MGL_SPLIT_ONLY_OR_SKIP(); }
TEST(SplitBufferSet, TheWholeSetIsInertOnTheMonolithPath) { MGL_SPLIT_ONLY_OR_SKIP(); }
TEST(SplitBufferSet, MembershipIsSyncPersistentMappedRangesOwnEarlyOutChain) { MGL_SPLIT_ONLY_OR_SKIP(); }
TEST(SplitBufferSet, TheAdoptedArmIsNotAMemberAndItIsTheChainRowThatSaysSo) { MGL_SPLIT_ONLY_OR_SKIP(); }
TEST(SplitBufferSet, ThePushCutsTheMappedSpanIntoBlocksAndMovesPmap) { MGL_SPLIT_ONLY_OR_SKIP(); }
TEST(SplitBufferSet, TheLastBlockIsTheRemainderAndNotAWholeBlock) { MGL_SPLIT_ONLY_OR_SKIP(); }
TEST(SplitBufferSet, BothEdgesOfAWriteMapPublishOneStateRecord) { MGL_SPLIT_ONLY_OR_SKIP(); }
+16 -12
View File
@@ -66,21 +66,25 @@ endif ()
gtest_discover_tests(PipeWireCodecTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
# P5 c1's and v1's suites. Both are registered on their own, and both for
# PipeWireCodecTest's reason - MGLOG_F writes to stdout and to a named file and NEVER to
# stderr, so a Fatal arm can only be asserted by forking and reading that file back, which
# needs a main() of its own. Both also reach MG_Pipe, MG_Backend and MG_State, so both carry
# P5 c1's, v1's and s1's suites. All three are registered on their own, and all three for
# PipeWireCodecTest's reason: a Fatal arm reports through MGLOG_F, which writes to stdout and to
# a named file and NEVER to stderr (and with the console sink compiled out, Defines.h, only to
# the file), so asserting one means naming a log file before anything logs - which needs a
# main() of its own. All three also reach MG_Pipe, MG_Backend and MG_State, so all three carry
# those include paths.
#
# RemoteClientTest the 71-slot emit table, the caps mirror, R-8's liveness gates and
# R-17's routing (c1)
# ServerLoopTest the apply thread, the blocking control mailbox, the verb stamp and
# R-11's server-owned staging copy (v1)
# RemoteClientTest the 71-slot emit table, the caps mirror, R-8's liveness gates, R-17's
# routing and ID-47/ID-49's readback rules (c1)
# ServerLoopTest the apply thread, the blocking control mailbox, the verb stamp and
# R-11's server-owned staging copy (v1)
# SessionHandshakeTest the two null-union guards driven THROUGH ServerSession::Accept and
# ClientSession::StartOverTransportPair, and the ABI fingerprint's
# sensitivity case driven from CapsAbiFingerprint() (s1, ID-46 6 and 7)
#
# THE ONLY CONFLICT IN THE c1/v1 MERGE, and it is the same-point-append shape BRIEF §5's
# ownership table exists to prevent: two packages each added a target at the end of one file.
# Both blocks are kept verbatim; nothing is chosen over anything.
foreach (wiretest IN ITEMS RemoteClientTest ServerLoopTest)
# THIS FILE IS THE PHASE'S ONE RECURRING MERGE CONFLICT, and it is the same-point-append shape
# BRIEF §5's ownership table exists to prevent: three packages, three targets, one end-of-file.
# The loop is what stops there being a fourth: a package adding a suite adds a NAME.
foreach (wiretest IN ITEMS RemoteClientTest ServerLoopTest SessionHandshakeTest)
add_executable(${wiretest} ${wiretest}.cpp)
target_include_directories(${wiretest} PRIVATE
+186
View File
@@ -34,6 +34,8 @@
#include "Includes.h"
// MG_Config::Transport and MG_Config::Ipc.AdoptTier: the two knobs R-6's tier gate reads.
#include <Config.h>
#include <MG_Pipe/MGPipe.h>
#include <MG_Pipe/MGPipeRenderStateSpans.h>
#include <MG_Pipe/PipeApply.h>
@@ -540,6 +542,37 @@ TEST_F(PipeWireCodecTest, KReplySlotMapPersistentIsAConstantDecline) {
EXPECT_TRUE(wire.Answers().All[0].Bytes.empty());
}
TEST_F(PipeWireCodecTest, TierTwoUnderSplitTransportStillDeclinesRatherThanRefusing) {
// The POSITIVE half of the two AdoptTier death cases below. Without it, those two could
// be satisfied by an arm that aborted on every tier, which is the opposite mistake to the
// one wave1-codex-verify.md §4 found. T2 is the only tier P5 implements and R-6 says the
// answer there is DECLINED - a real answer, not a failure - even when the transport is
// the split one that makes the tier question live at all.
const MG_Config::TransportMode savedTransport = MG_Config::Transport;
const Uint32 savedTier = MG_Config::Ipc.AdoptTier;
struct Restore {
MG_Config::TransportMode T;
Uint32 A;
~Restore() {
MG_Config::Transport = T;
MG_Config::Ipc.AdoptTier = A;
}
} restore{savedTransport, savedTier};
MG_Config::Transport = MG_Config::TransportMode::InProcess;
MG_Config::Ipc.AdoptTier = 2u;
Wire2 wire;
const MGPHandleOnly handle = HandleOnly(5, MGPipeKind::Buffer);
ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::MapPersistent, &handle, sizeof(handle)),
kInvalidSeq);
bool applied = false;
ASSERT_TRUE(wire.PumpOne(&applied));
EXPECT_TRUE(applied);
ASSERT_EQ(wire.Answers().All.size(), 1u);
EXPECT_EQ(wire.Answers().All[0].Status, ReplySink::kStatusDeclined);
EXPECT_TRUE(wire.Answers().All[0].Bytes.empty());
}
TEST_F(PipeWireCodecTest, KNeedsAckRespecifyCarriesItsRedefinitionScope) {
// Contract table 1 row 19b. Without the carrier every per-level glTexImage*D would take
// the whole-resource arm on the far side and eat the other levels' pending uploads, so
@@ -1174,6 +1207,43 @@ TEST_F(PipeWireCodecTest, StagedBytesAreReclaimedOnlyBehindRetiredSeq) {
ASSERT_TRUE(wire.PumpOne(&applied));
wire.Encoder().ReclaimStagedBytes();
EXPECT_EQ(wire.Encoder().StagedBytesInFlight(), 0u);
// ---- AND AN EMPTY STAGE TAKES THE WHOLE SEGMENT ---------------------------------
// The verifier's extension of this case, kept (wave1-codex-verify.md §1). The 64-byte
// run has retired and in-flight bytes are ZERO, so every byte of SEG_STAGE is free -
// but head and tail are monotonic and both sit at 64, so `head % capacity` is 64 and the
// allocator used to charge a `capacity - 64` wrap skip against a capacity that had
// nothing in it. The test then read `2*capacity - 64 <= capacity`, false at every
// occupancy, and a blob the segment holds WHOLE aborted with
// `Fatal{RingOverrun, "SEG_STAGE"} ... with 0 bytes still in flight`.
//
// I made it red once, by doing X: X = deleting the `RebaseEmptyStage()` call at the top
// of StageAllocate's attempt loop (PipeWireCodec.cpp). The case then dies with SIGABRT
// inside PipeWireEncoder::StageAllocate on that message, exactly as the verifier
// recorded it.
//
// THE EXACT MAXIMUM. `need = Align8(size)` and the first bound is `need > capacity`, so
// an empty stage takes a blob of exactly the capacity the encoder adopted - here
// Wire2::kStageBytes, and in a real session the whole SEG_STAGE view, i.e.
// MOBILEGL_IPC_STAGE_MB (32 MiB by default; SessionRings.h keeps SEG_STAGE un-ringed and
// un-rounded, so there is no control page to subtract).
const Uint64 maxRecordBefore = wire.Encoder().MaxRecordBytesSeen();
std::vector<std::uint8_t> whole(Wire2::kStageBytes, 0x5A);
const MGPBlobRef full = wire.Encoder().StageBytes(whole.data(), whole.size());
EXPECT_EQ(full.Offset, 0u) << "an empty stage must hand a whole-capacity blob offset zero";
EXPECT_EQ(full.Size, Wire2::kStageBytes);
EXPECT_EQ(full.Seg, static_cast<Uint32>(kSegStage));
EXPECT_EQ(wire.Encoder().StagedBytesInFlight(), Wire2::kStageBytes);
const void* back = wire.Segments().Resolve(full.Seg, full.Offset, full.Size);
ASSERT_NE(back, nullptr);
EXPECT_EQ(back, wire.StageBase());
// R-10's max-record counter DOES NOT SEE IT, and that is the point of R-10's carrier
// rule: EncodeRecord feeds m_maxRecordBytes from `layout.TotalBytes` - header + payload +
// tails, all of it SEG_CMD - while the blob leaves only {Seg, Offset, Size} in the
// record. A quarter-megabyte of staging moved the counter by zero bytes. SEG_STAGE has
// its own bound and its own named Fatal, and MaxRecordBytesSeen() is not it.
EXPECT_EQ(wire.Encoder().MaxRecordBytesSeen(), maxRecordBefore);
}
// ---- M2 / M3: SEG_STAGE's cursors and the mark queue --------------------------------------
@@ -1555,6 +1625,69 @@ TEST_F(PipeWireCodecTest, ARunThatLeavesItsSegmentIsFatal) {
EXPECT_NE(r.Log.find("does not lie inside that segment"), std::string::npos) << r.Log;
}
TEST_F(PipeWireCodecTest, AContentBlobCarriedOutsideSegStageIsFatalAtTheDecoder) {
// R-2.3's second half, and the verifier's finding-3 fixture kept as its own case
// (wave1-codex-verify.md §3). Contract table 1 row 17 puts CreateSamplerState's bytes in
// SEG_STAGE; here they sit in a mapped SEG_REPLY - the SERVER-owned reply pool, whose
// reuse has nothing to do with stage retirement - and the record names that segment. It
// used to be ACCEPTED and APPLIED, because the only test was that the run resolved
// somewhere: the verifier's probe printed `seg=3 accepted=1 poisoned=0`.
//
// The audit is armed, so the second half of the finding is nailed down too: with the
// poison ON, the record must DIE rather than be applied with PoisonedStageBytes() left at
// zero. NoteResolvedRun used to return silently for any non-stage carrier, which made
// rule C's only mechanical control dark on exactly the record it exists to catch; it is
// now a Fatal of its own and unreachable behind this arm.
//
// I made it red once, by doing X: X = deleting the `blob.Seg != kSegStage` arm in
// CheckBlobIsHonest (PipeWireCodec.cpp). The child then exits 0 instead of aborting and
// this case fails on DiedOfAbort - the verifier's `accepted=1` state.
const ChildResult r = RunInChild([] {
Wire2 wire;
std::vector<std::uint8_t> replyBytes(4096, 0);
SamplerParameters params{};
params.borderColorForm = BorderColorForm::Int;
std::memcpy(replyBytes.data(), &params, sizeof(params));
wire.Segments().Install(kSegReply, SegmentView{replyBytes.data(), replyBytes.size()});
wire.Decoder().SetAuditPoison(true);
MGPSamplerDesc desc{};
desc.Cso = MakeHandle(88);
desc.Parameters.Seg = static_cast<Uint32>(kSegReply);
desc.Parameters.Offset = 0;
desc.Parameters.Size = sizeof(SamplerParameters);
ForgeAndDecode(wire, MGPWireOp::CreateSamplerState, &desc, sizeof(desc), nullptr, 0);
});
ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log;
EXPECT_NE(r.Log.find("is not SEG_STAGE"), std::string::npos) << r.Log;
EXPECT_NE(r.Log.find("CreateSamplerState.blob"), std::string::npos) << r.Log;
EXPECT_NE(r.Log.find("seg=3"), std::string::npos) << r.Log;
}
TEST_F(PipeWireCodecTest, TheEncoderRefusesTheNonStageCarrierTheDecoderCallsFatal) {
// THE ENCODER MUST NOT ACCEPT A RECORD THE DECODER FATALS ON - the same symmetry
// TheEncoderRefusesThePerStageSpirvRunTheDecoderCallsFatal states one arm over. Under
// `inproc` a SEG_REPLY pointer resolves, so an emitter that staged into the reply pool
// would get a valid seq here and a Fatal on a peer, which is the asymmetry EncodeRecord's
// own honesty loop exists to prevent.
//
// I made it red once, by doing X: X = deleting the `blob.Seg != kSegStage` arm in
// CheckBlobIsHonest. EncodeRecord then returns a real seq and the child exits 0.
const ChildResult r = RunInChild([] {
Wire2 wire;
std::vector<std::uint8_t> replyBytes(4096, 0);
wire.Segments().Install(kSegReply, SegmentView{replyBytes.data(), replyBytes.size()});
MGPSamplerDesc desc{};
desc.Cso = MakeHandle(88);
desc.Parameters.Seg = static_cast<Uint32>(kSegReply);
desc.Parameters.Offset = 0;
desc.Parameters.Size = sizeof(SamplerParameters);
(void)wire.Encoder().EncodeRecord(MGPWireOp::CreateSamplerState, &desc, sizeof(desc));
});
ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log;
EXPECT_NE(r.Log.find("is not SEG_STAGE"), std::string::npos) << r.Log;
}
TEST_F(PipeWireCodecTest, AHalfDeclaredBlobIsFatalRatherThanReadAsAbsent) {
// The shape a MONOLITH emitter produces - Seg None, Offset a host address, Size 0. Reading
// it as "absent" would silently drop the bytes of every record an unconverted emitter sent.
@@ -1810,6 +1943,59 @@ TEST_F(PipeWireCodecTest, ASecondProcessResolverIsFatalRatherThanASilentRace) {
EXPECT_NE(r.Log.find("already installed"), std::string::npos) << r.Log;
}
// ---- R-6 / contract §5: the two forbidden adoption tiers die ON THE WIRE PATH TOO --------
//
// wave1-codex-verify.md §4: `AdoptTier` had ZERO references anywhere on the codec path, so
// MOBILEGL_IPC_ADOPT_TIER=0 and =1 - which contract §5 promises "parse and are Fatal at use,
// naming P11" - decoded as an ordinary DECLINED. The verifier set each forbidden tier inside
// KReplySlotMapPersistentIsAConstantDecline and watched its successful-decline assertions
// still pass, on BOTH tiers.
//
// THESE ARE FORKED, NOT EXPECT_DEATH, for the reason at the top of this file - and forking is
// what lets the case REQUIRE THE DIAGNOSTIC rather than any abort: r.Log is searched for the
// exact sentence AdoptTierIsEmulate prints. ID-46 finding 10 is an empty death regex; the
// EXPECT_NE lines below are the opposite of that, and a crash for any other reason fails the
// case on the log it prints.
//
// I made both red once, by doing X: X = restoring the unconditional decline in
// PipeWireCodec.cpp's MapPersistent arm (deleting the AdoptTierIsEmulate call). Both children
// then exit 0 having posted a clean DECLINED, and both cases fail on DiedOfAbort.
TEST_F(PipeWireCodecTest, AdoptTierZeroIsFatalOnTheWirePathAndNamesP11) {
const ChildResult r = RunInChild([] {
// The child dies; nothing needs restoring. The transport half is the same conjunction
// MGPipeApplyMapPersistent uses - a monolith TRANSPORT mints like push (ID-42) and is
// not the arm this record can arrive on.
MG_Config::Transport = MG_Config::TransportMode::InProcess;
MG_Config::Ipc.AdoptTier = 0u;
Wire2 wire;
const MGPHandleOnly handle = HandleOnly(5, MGPipeKind::Buffer);
(void)wire.Encoder().EncodeRecord(MGPWireOp::MapPersistent, &handle, sizeof(handle));
bool applied = false;
(void)wire.PumpOne(&applied);
});
ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log;
EXPECT_NE(r.Log.find("MOBILEGL_IPC_ADOPT_TIER=0 names adoption tier T0, which P11 implements"),
std::string::npos)
<< r.Log;
}
TEST_F(PipeWireCodecTest, AdoptTierOneIsFatalOnTheWirePathAndNamesP11) {
const ChildResult r = RunInChild([] {
MG_Config::Transport = MG_Config::TransportMode::InProcess;
MG_Config::Ipc.AdoptTier = 1u;
Wire2 wire;
const MGPHandleOnly handle = HandleOnly(5, MGPipeKind::Buffer);
(void)wire.Encoder().EncodeRecord(MGPWireOp::MapPersistent, &handle, sizeof(handle));
bool applied = false;
(void)wire.PumpOne(&applied);
});
ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log;
EXPECT_NE(r.Log.find("MOBILEGL_IPC_ADOPT_TIER=1 names adoption tier T1, which P11 implements"),
std::string::npos)
<< r.Log;
}
#else
TEST_F(PipeWireCodecTest, TheFatalArmsNeedFork) {
+10 -1
View File
@@ -69,11 +69,15 @@ TEST(ProtocolSmokeTest, HelloRoundTrips) {
EXPECT_EQ(envelope->msg_as_Welcome(), nullptr);
}
// The four canonical sizes: 8 MiB / 32 MiB / 16 MiB / 256 KiB. SEG_REPLY is 16 MiB by ID-47
// (eight slots of 2 MiB, sized from the largest P5 read - E2's 640x480 RGBA8 snapshot - which
// the previous 8 MiB pool could not hold); SessionTest pins the same number on the mapping and
// CONTRACT-P5 §2 row 23 on paper. All three move together or not at all.
TEST(ProtocolSmokeTest, WelcomeCarriesTheFourSegmentAnnouncements) {
::flatbuffers::FlatBufferBuilder builder(1024);
auto cmd = CreateSegmentRefDirect(builder, 1, SegmentKind::Cmd, 8ull * 1024 * 1024, "cmd");
auto stage = CreateSegmentRefDirect(builder, 2, SegmentKind::Stage, 32ull * 1024 * 1024, "stage");
auto reply = CreateSegmentRefDirect(builder, 3, SegmentKind::Reply, 8ull * 1024 * 1024, "reply");
auto reply = CreateSegmentRefDirect(builder, 3, SegmentKind::Reply, 16ull * 1024 * 1024, "reply");
auto event = CreateSegmentRefDirect(builder, 4, SegmentKind::Event, 256ull * 1024, "event");
auto welcome = CreateWelcome(builder, MOBILEGL_PROTOCOL_ABI_MAJOR, MOBILEGL_PROTOCOL_ABI_MINOR,
/*serverPid=*/99, cmd, stage, reply, event);
@@ -91,6 +95,11 @@ TEST(ProtocolSmokeTest, WelcomeCarriesTheFourSegmentAnnouncements) {
EXPECT_EQ(parsed->cmdRing()->sizeBytes(), 8ull * 1024 * 1024);
ASSERT_NE(parsed->stageRing(), nullptr);
EXPECT_EQ(parsed->stageRing()->sizeBytes(), 32ull * 1024 * 1024);
// The pin never read the reply announcement back before ID-47; a size that moved on the
// wire and not here would have gone unnoticed.
ASSERT_NE(parsed->replyPool(), nullptr);
EXPECT_EQ(parsed->replyPool()->kind(), SegmentKind::Reply);
EXPECT_EQ(parsed->replyPool()->sizeBytes(), 16ull * 1024 * 1024);
ASSERT_NE(parsed->eventRing(), nullptr);
EXPECT_EQ(parsed->eventRing()->sizeBytes(), 256ull * 1024);
}
+25 -12
View File
@@ -34,6 +34,7 @@
#include <MG_Pipe/MGPipe.h>
#include <MG_Remote/CapsCodec.h>
#include <MG_Remote/Client/CapsMirror.h>
#include <MG_Remote/Transport/ReplySlot.h>
#include <MG_Pipe/PipeRoute.h>
#include <MG_Remote/Client/EmitTables.h>
@@ -407,32 +408,44 @@ TEST(RemoteEmitTable, TheE2DropSwitchStartsDisarmed) {
// =====================================================================================
TEST(RemoteReadback, ExactlyTheCapacityPassesAndOneByteMoreIsRefusedByName) {
// THE BOUNDARY PAIR ID-47 ASKS FOR, driven through the emitter's own decision function.
// It is not driven through EmitReadPixels, and that is the point: EmitReadPixels needs a
// live session before it reaches any of this, so a control over the emitter could only
// ever observe Fatal{NoClientSession} and would be green for the wrong reason.
constexpr Uint64 kCap = 2u * 1024u * 1024u - 16u; // ID-47's post-growth MaxReplyBytes
// ID-47's boundary pair, and it is THE CALL SITE'S half. The refusal itself is s1's
// (ReplySlotPool::RequireReadPixelsFits, its own cases in s1-v3.md §1); what c1 owns is
// that the number handed to it is the one the emitter computes - ID-49's TIGHT extent -
// and that exactly the cap is legal while one byte more is not. So this drives the real
// pool with the real helper, over the real arithmetic, and never restates the message.
//
// A real pool over a real mapping, because CanHold answers false for a null base and a
// control built on a default-constructed pool would "refuse" everything for that reason.
constexpr std::uint32_t kSlots = 8;
constexpr std::uint64_t kSlotBytes = 2u * 1024u * 1024u;
std::vector<Uint8> backing(static_cast<size_t>(kSlots) * kSlotBytes);
Transport::ReplySlotPool pool(backing.data(), backing.size(), kSlots);
const std::uint64_t cap = pool.MaxReplyBytes();
ASSERT_GT(cap, 0u) << "the fixture's pool is not configured, so every answer would be refused";
// The passing half runs IN THIS PROCESS, because "it did not abort" is only a statement if
// the thing that would have aborted is the same code.
RefuseReadbackLargerThanTheReplySlot(724, 724, 0x1908 /*GL_RGBA*/, kCap, kCap);
// ID-47's own number: the E2 retrace snapshot reads 640x480 RGBA8 and it must now FIT.
EXPECT_TRUE(pool.CanHold(TightReadbackByteCount(640, 480, 0x1908, 0x1401)))
<< "the read ID-47 grew SEG_REPLY for still does not fit";
pool.RequireReadPixelsFits(724, 724, 0x1908, 0x1401, cap);
SUCCEED() << "exactly the capacity is not an overflow";
#if MGTEST_HAVE_FORK
const ChildResult child = RunInChild([&] {
RefuseReadbackLargerThanTheReplySlot(640, 480, 0x1908 /*GL_RGBA*/, kCap + 1, kCap);
Transport::ReplySlotPool inner(backing.data(), backing.size(), kSlots);
inner.RequireReadPixelsFits(640, 480, 0x1908, 0x1401, inner.MaxReplyBytes() + 1);
});
EXPECT_TRUE(DiedOfAbort(child)) << "one byte over the slot did not abort: " << DescribeStatus(child);
// ITS OWN FAILURE STRING, AND THE READ'S OWN NUMBERS. A control that only asserted
// "something died" would pass on Fatal{NoClientSession}, Fatal{UnmigratedVerb} or a
// segfault, and this file has three other cases that abort for those reasons.
// segfault, and this file has four other cases that abort for those reasons.
EXPECT_NE(child.Log.find("Fatal{ReplyTooLarge"), std::string::npos) << child.Log;
EXPECT_NE(child.Log.find("ReadPixels 640x480"), std::string::npos)
<< "the message does not name the read, so an operator cannot tell which one: " << child.Log;
EXPECT_NE(child.Log.find(std::to_string(kCap + 1)), std::string::npos)
EXPECT_NE(child.Log.find(std::to_string(cap + 1)), std::string::npos)
<< "the message does not carry the byte count";
#else
GTEST_SKIP() << "the refusal reports through MGLOG_F + abort and needs fork() to read back";
GTEST_SKIP() << "the refusal reports through a Fatal + abort and needs fork() to read back";
#endif
}
+60 -8
View File
@@ -702,9 +702,11 @@ TEST(RingTest, TheTwoFlagSpacesAreDisjointByTranslation) {
static_assert(Call(kHasBlob) == Rec(kRecHasBlob), "bit 1 stopped agreeing");
// The three that collide. Each of these is a live defect if it is ever passed through,
// and the middle one is the worst: RingConsumer::Pop treats kRecPad as a wrap filler and
// SKIPS the record, so a stamped kVarTail deletes every variable-tail call from the
// stream with no error raised anywhere.
// and the middle one was the worst: kRecPad is the flag RingConsumer::Pop reads as "wrap
// filler", so on the flag alone a stamped kVarTail would delete every variable-tail call
// from the stream with no error raised anywhere. Pop does not read it on the flag alone -
// it requires kind == kRingPadRecordKind beside it - and the runtime half below is the
// control on exactly that.
static_assert(Call(kVarTail) == Rec(kRecPad), "the kVarTail/kRecPad collision moved");
static_assert(Call(kHostSpan) == Rec(kRecBorrowSlot), "the kHostSpan/kRecBorrowSlot collision moved");
static_assert(Call(kReplySlot) == Rec(kRecVarTail), "the kReplySlot/kRecVarTail collision moved");
@@ -757,8 +759,17 @@ TEST(RingTest, TheTwoFlagSpacesAreDisjointByTranslation) {
// The one that Reserve cannot defend: a producer that writes the header ITSELF rather than
// letting Reserve write it - which is precisely what a codec with its own header struct
// does, since MGPWireRecHeader and RingRecordHeader are the same eight bytes. Then the
// mask is not in the path, Pop sees kRecPad, and the record is skipped as a wrap filler
// with no error raised anywhere.
// mask is not in the path and Pop sees kRecPad on a record that is not a filler.
//
// What saves it is the KIND. RingConsumer::Pop skips a record only when it carries kRecPad
// AND kind == kRingPadRecordKind (Ring.cpp: `(header.flags & kRecPad) != 0 && header.kind ==
// kRingPadRecordKind` - "BOTH, not just the flag"). Kind 0 is the wrap filler's and nothing
// else's, because the call catalogue starts at 1. So the stamped record is DELIVERED, lies
// and all, and the decoder can reject it by name - which it can only do because it got it.
// THE ASSERT AND THE EXPECTS BELOW ARE THE CONTROL ON THAT KIND CHECK: delete the
// `&& header.kind == kRingPadRecordKind` half of Pop's condition and this record vanishes
// into the wrap-filler skip again, exactly as it did before the pair was required, and this
// case goes red on the ASSERT's own message. That perturbation was run.
void* second = ring.Producer().Reserve(static_cast<std::uint16_t>(MGPWireOp::DrawVbo),
kRecNone, 16);
ASSERT_NE(second, nullptr);
@@ -771,10 +782,51 @@ TEST(RingTest, TheTwoFlagSpacesAreDisjointByTranslation) {
sizeof(stamped));
ring.Producer().Publish();
ASSERT_TRUE(ring.Consumer().Pop(view))
<< "the stamped record vanished into Pop's wrap-filler skip. Pop's kind check - a filler "
"must carry kind == kRingPadRecordKind as well as kRecPad - is the only thing standing "
"between a header stamped with the CALL flags and every var-tail call being deleted "
"from the stream with nothing logged on either side";
EXPECT_EQ(view.kind, static_cast<std::uint16_t>(MGPWireOp::DrawVbo))
<< "kind is what Pop tells a real record from a filler by, and a filler's is "
"kRingPadRecordKind";
// Delivered is not the same as correct. The stamped bits arrive verbatim, and they are
// exactly the collisions the table above names: bit 2 (kVarTail -> kRecPad) is why this
// record looked like a filler at all, and bit 3 (kHostSpan -> kRecBorrowSlot) still makes it
// claim a slot in the GPU timeline it never borrowed. draw_vbo is not a kReplySlot call, so
// bit 4 (kReplySlot -> kRecVarTail) is clear here; on a blocking call it would lie too.
EXPECT_EQ(view.flags, static_cast<std::uint16_t>(drawVboCallFlags))
<< "the header did not arrive as it was stamped";
EXPECT_NE(view.flags & static_cast<std::uint16_t>(kRecPad), 0u)
<< "the kVarTail/kRecPad collision arrives intact - the kind check narrows the SKIP, it "
"does not scrub the bit, and naming this record is the decoder's job";
EXPECT_NE(view.flags & static_cast<std::uint16_t>(kRecBorrowSlot), 0u)
<< "the kHostSpan/kRecBorrowSlot collision is what makes a stamped header lie about "
"this record's lifetime";
EXPECT_EQ(view.flags & static_cast<std::uint16_t>(kRecVarTail), 0u)
<< "draw_vbo started carrying kReplySlot; then the third collision lies here too";
EXPECT_EQ(view.payloadSize, 16u);
EXPECT_TRUE(ring.Invariants());
// The other side of the same control, so that "the record is popped" cannot be satisfied by
// simply not skipping anything: kRecPad on a header whose kind IS kRingPadRecordKind is a
// genuine wrap filler and still vanishes. Pop's check was narrowed to the pair, not removed.
void* filler = ring.Producer().Reserve(kRingPadRecordKind, kRecNone, 16);
ASSERT_NE(filler, nullptr);
std::memset(filler, 0xEF, 16);
RingRecordHeader asFiller{};
std::memcpy(&asFiller, static_cast<std::uint8_t*>(filler) - sizeof(RingRecordHeader),
sizeof(asFiller));
asFiller.flags = static_cast<std::uint16_t>(kRecPad);
std::memcpy(static_cast<std::uint8_t*>(filler) - sizeof(RingRecordHeader), &asFiller,
sizeof(asFiller));
ring.Producer().Publish();
EXPECT_FALSE(ring.Consumer().Pop(view))
<< "a header stamped with the CALL flags outside Reserve should vanish into Pop's "
"wrap-filler skip - if it did not, the collision has moved and this case is no "
"longer the control it was";
<< "a header carrying BOTH kRecPad and kind kRingPadRecordKind is a wrap filler and has "
"to be skipped; if it reaches a caller the skip is gone, not narrowed";
EXPECT_TRUE(ring.Invariants());
// And the same record framed the way the encoder actually frames it - translated, with the
// ring's own var-tail bit - round-trips intact.
@@ -0,0 +1,292 @@
// MobileGL - MobileGL/MG_Test/Wire/SessionHandshakeTest.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
// The two handshakes, driven for real (P5 s1, wave 1.5 - ID-46 findings 6 and 7).
//
// WHY THIS SUITE EXISTS BESIDE SessionTest. SessionTest is the ring-owning suite and keeps the
// GL frontend's umbrella header out on purpose. Both of the wave-1 review's findings against it
// were the same defect seen twice: a case that observed a property of the thing it built itself
// - a null-union frame it never sent anywhere, a mixer production never called - and so could
// not go red when the production code it was named for was deleted. The cure for both is to
// start from the production entry point, and the production entry points (ServerSession::Accept,
// ClientSession::StartOverTransportPair, CapsAbiFingerprint) all reach Includes.h. So they are
// exercised here, in a target that carries the include paths and links gtest rather than
// gtest_main: each guard's refusal is asserted BY MESSAGE, and with the console sink compiled
// out (Defines.h) an MGLOG line reaches exactly one place, the log file this process names
// before anything logs - PipeWireCodecTest's main() shape.
//
// EVERY CASE BELOW CARRIES ITS RED-ONCE LINE, and each of those perturbations was run.
#include <gtest/gtest.h>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <memory>
#include <sstream>
#include <string>
#include <utility>
#include <vector>
#include "Includes.h"
#include <MG_Remote/CapsCodec.h>
#include <MG_Remote/Client/ClientSession.h>
#include <MG_Remote/Protocol/generated/protocol_generated.h>
#include <MG_Remote/Server/ServerSession.h>
#include <MG_Remote/Transport/InProcessTransport.h>
#include <MG_Remote/Transport/SessionRings.h>
#if __has_include(<MGGitHash.h>)
#include <MGGitHash.h>
#define MGL_HANDSHAKE_TEST_HAS_GIT_HASH 1
#else
#define MGL_HANDSHAKE_TEST_HAS_GIT_HASH 0
#endif
#if defined(_WIN32)
#include <process.h>
#else
#include <unistd.h>
#endif
using namespace MobileGL;
using namespace MobileGL::MG_Remote;
namespace Transport = MobileGL::MG_Remote::Transport;
namespace {
std::string g_logPath;
std::string ReadLog() {
std::ifstream in(g_logPath, std::ios::binary);
std::ostringstream ss;
ss << in.rdbuf();
return ss.str();
}
long ProcessId() {
#if defined(_WIN32)
return static_cast<long>(::_getpid());
#else
return static_cast<long>(::getpid());
#endif
}
bool Contains(const std::string& haystack, const char* needle) {
return haystack.find(needle) != std::string::npos;
}
// The reviewer's 24-byte shape (SessionTest.ANullUnionFrameVerifiesWhichIsThePremiseOf-
// BothHandshakeGuards proves it verifies): the envelope's tag says `tag` and its union
// member is NULL, because FlatBuffers' Verifier::VerifyTable is `return !table ||
// table->Verify(*this)`.
std::vector<Uint8> BuildNullUnionFrame(::MobileGL::Wire::CtrlMsg tag) {
::flatbuffers::FlatBufferBuilder builder(256);
auto envelope =
::MobileGL::Wire::CreateCtrlEnvelope(builder, tag, ::flatbuffers::Offset<void>());
::MobileGL::Wire::FinishCtrlEnvelopeBuffer(builder, envelope);
const Uint8* begin = builder.GetBufferPointer();
return std::vector<Uint8>(begin, begin + builder.GetSize());
}
} // namespace
// ---------------------------------------------------------------------------
// The ABI fingerprint, from the production entry point (ID-46 finding 6)
// ---------------------------------------------------------------------------
// The value Hello and Welcome carry is CapsAbiFingerprint(). This case starts THERE, requires it
// to be the mixer over its own published inputs, pins those inputs to the real sizeofs and
// constants (ID-33's list: the three struct sizes, kOpCount, the protocol ABI version, the git
// stamp - plus the caps blob's extents and the two codec versions), and then perturbs every
// input by one through the same mixer and requires the answer to move. RED ONCE by replacing
// CapsAbiFingerprint()'s body with `return 1;` - the verifier's exact perturbation, which left
// the whole unit lane green before this case existed - and it fails on the first EXPECT_EQ
// below. Also red, separately, by deleting any one `mix(...)` line from MixAbiFingerprint: the
// matching EXPECT_NE names the input that stopped being mixed. Both perturbations were run.
TEST(SessionHandshakeTest, TheAbiFingerprintChangesWhenAnyOfItsInputsDoes) {
const Uint64 production = CapsAbiFingerprint();
EXPECT_NE(production, 0u) << "0 is reserved for \"not stated\"";
EXPECT_EQ(production, CapsAbiFingerprint()) << "not stable within one build";
const Transport::AbiFingerprintInputs inputs = CapsAbiFingerprintInputs();
EXPECT_EQ(production, Transport::MixAbiFingerprint(inputs))
<< "CapsAbiFingerprint() is not MixAbiFingerprint over CapsAbiFingerprintInputs(): the "
"handshake compares a value this case cannot reach, which is finding 6 again";
// The inputs are the real ones, so a CapsAbiFingerprintInputs() that hard-coded a size
// would be caught here rather than agreed with by a peer built from a different tree.
EXPECT_EQ(inputs.DynamicParamsSize, sizeof(MG_Backend::DynamicBackendParameters));
EXPECT_EQ(inputs.CapsSize, sizeof(MG_Pipe::MGPCaps));
EXPECT_EQ(inputs.FunctionTableSize, sizeof(MG_Backend::GLFunctionsTable));
EXPECT_EQ(inputs.FormatCapabilityTargets,
static_cast<Uint64>(MG_Backend::kFormatCapabilityTargetCount));
EXPECT_EQ(inputs.FormatCapabilityFormats,
static_cast<Uint64>(MG_Backend::kFormatCapabilityFormatCount));
EXPECT_NE(inputs.FormatCapabilitiesCodecVersion, 0u);
EXPECT_NE(inputs.RendererInfoCodecVersion, 0u);
EXPECT_EQ(inputs.OpCount, static_cast<Uint64>(MG_Pipe::MGPWireOp::kOpCount));
EXPECT_EQ(inputs.AbiVersion, static_cast<Uint32>(MOBILEGL_ABI_VERSION(
MOBILEGL_PROTOCOL_ABI_MAJOR, MOBILEGL_PROTOCOL_ABI_MINOR)));
ASSERT_NE(inputs.BuildStamp, nullptr);
EXPECT_NE(inputs.BuildStamp[0], '\0');
#if MGL_HANDSHAKE_TEST_HAS_GIT_HASH
EXPECT_STREQ(inputs.BuildStamp, GIT_COMMIT_HASH_SHORT);
#endif
// Every input moves the answer. Each lambda changes exactly one field of a copy of the
// REAL inputs, so what is proven is that the production value depends on that field.
const auto perturbed = [&](auto&& mutate) {
Transport::AbiFingerprintInputs copy = inputs;
mutate(copy);
return Transport::MixAbiFingerprint(copy);
};
EXPECT_NE(production, perturbed([](Transport::AbiFingerprintInputs& i) { ++i.DynamicParamsSize; }))
<< "sizeof(DynamicBackendParameters) is not mixed";
EXPECT_NE(production, perturbed([](Transport::AbiFingerprintInputs& i) { ++i.CapsSize; }))
<< "sizeof(MGPCaps) is not mixed";
EXPECT_NE(production, perturbed([](Transport::AbiFingerprintInputs& i) { ++i.FunctionTableSize; }))
<< "sizeof(GLFunctionsTable) is not mixed";
EXPECT_NE(production,
perturbed([](Transport::AbiFingerprintInputs& i) { ++i.FormatCapabilityTargets; }))
<< "kFormatCapabilityTargetCount is not mixed";
EXPECT_NE(production,
perturbed([](Transport::AbiFingerprintInputs& i) { ++i.FormatCapabilityFormats; }))
<< "kFormatCapabilityFormatCount is not mixed";
EXPECT_NE(production, perturbed([](Transport::AbiFingerprintInputs& i) {
++i.FormatCapabilitiesCodecVersion;
}))
<< "kFormatCapabilitiesCodecVersion is not mixed";
EXPECT_NE(production,
perturbed([](Transport::AbiFingerprintInputs& i) { ++i.RendererInfoCodecVersion; }))
<< "kRendererInfoCodecVersion is not mixed";
EXPECT_NE(production, perturbed([](Transport::AbiFingerprintInputs& i) { ++i.OpCount; }))
<< "MGPWireOp::kOpCount is not mixed (ID-33)";
EXPECT_NE(production, perturbed([](Transport::AbiFingerprintInputs& i) { ++i.AbiVersion; }))
<< "the protocol ABI version is not mixed (ID-33)";
EXPECT_NE(production,
perturbed([](Transport::AbiFingerprintInputs& i) { i.BuildStamp = "not-this-build"; }))
<< "the git stamp is not mixed";
// A missing stamp is not the same as an empty one, and neither is the same as a real build.
EXPECT_NE(production, perturbed([](Transport::AbiFingerprintInputs& i) { i.BuildStamp = nullptr; }));
EXPECT_NE(production, perturbed([](Transport::AbiFingerprintInputs& i) { i.BuildStamp = ""; }));
EXPECT_NE(perturbed([](Transport::AbiFingerprintInputs& i) { i.BuildStamp = nullptr; }),
perturbed([](Transport::AbiFingerprintInputs& i) { i.BuildStamp = ""; }))
<< "\"no stamp\" and \"an empty stamp\" collapsed into one input";
}
// ---------------------------------------------------------------------------
// The two null-union guards, driven THROUGH the handshakes (ID-46 finding 7)
// ---------------------------------------------------------------------------
// A frame whose tag says Hello and whose Hello is NULL, sent on a real InProcessTransport pair to
// a real ServerSession::Accept - a session of this case's own, not the process singleton. Accept
// must answer MOBILEGL_ERR_PROTOCOL_MISMATCH with its guard's own line, and must not have
// dereferenced the member: nothing Fatal in the log, nothing accepted. RED ONCE by deleting
// `envelope->msg_as_Hello() == nullptr` from the guard in ServerSession.cpp: the tag check
// passes, `hello` is nullptr, and `hello->buildFingerprint()` reads address 0 - the case dies
// instead of returning. That perturbation was run.
TEST(SessionHandshakeTest, ANullUnionHelloIsRefusedByAcceptRatherThanDereferenced) {
std::unique_ptr<Transport::InProcessTransport> client;
std::unique_ptr<Transport::InProcessTransport> server;
Transport::InProcessTransport::CreatePair(client, server);
ASSERT_NE(client, nullptr);
ASSERT_NE(server, nullptr);
const std::vector<Uint8> frame = BuildNullUnionFrame(::MobileGL::Wire::CtrlMsg::Hello);
ASSERT_EQ(client->SendFrame(MobileGLByteSpan{frame.data(), frame.size()}), MOBILEGL_OK);
Server::ServerSession session;
const std::string before = ReadLog();
EXPECT_EQ(session.Accept(*server), MOBILEGL_ERR_PROTOCOL_MISMATCH)
<< "a null-union Hello was not refused by the handshake";
EXPECT_FALSE(session.Accepted());
const std::string delta = ReadLog().substr(before.size());
EXPECT_TRUE(Contains(delta, "MG_Remote server: the first control frame is not a verifiable Hello"))
<< "Accept refused, but not with the guard's own line. Log delta:\n"
<< delta;
EXPECT_FALSE(Contains(delta, "Fatal{")) << "the refusal became a Fatal. Log delta:\n" << delta;
session.Close();
}
// The Welcome guard. ClientSession::Start builds its transport pair itself, so nothing could put
// a frame on the server->client direction ahead of the server's Welcome - which is why
// StartOverTransportPair, Start's second half, is public (ClientSession.h). The frame is queued
// there BEFORE Start sends Hello: the server's Accept then runs for real (segments, Welcome,
// resolver), its genuine Welcome queues behind the null-union one, the client reads the
// null-union one first and must refuse it with the guard's own line, and Stop()'s not-started
// path must have closed the server the handshake had accepted. RED ONCE by deleting
// `envelope->msg_as_Welcome() == nullptr` from the guard in ClientSession.cpp: `welcome` is then
// nullptr and `welcome->buildFingerprint()` reads address 0 - the case dies. That perturbation
// was run.
TEST(SessionHandshakeTest, ANullUnionWelcomeIsRefusedByStartRatherThanDereferenced) {
std::unique_ptr<Transport::InProcessTransport> client;
std::unique_ptr<Transport::InProcessTransport> server;
Transport::InProcessTransport::CreatePair(client, server);
ASSERT_NE(client, nullptr);
ASSERT_NE(server, nullptr);
const std::vector<Uint8> frame = BuildNullUnionFrame(::MobileGL::Wire::CtrlMsg::Welcome);
ASSERT_EQ(server->SendFrame(MobileGLByteSpan{frame.data(), frame.size()}), MOBILEGL_OK);
Client::ClientSession& session = Client::ClientSessionInstance();
Server::ServerSession& serverSession = Server::ServerSessionInstance();
ASSERT_FALSE(session.Started());
ASSERT_FALSE(serverSession.Accepted());
const std::string before = ReadLog();
EXPECT_EQ(session.StartOverTransportPair(std::move(client), std::move(server)),
MOBILEGL_ERR_PROTOCOL_MISMATCH)
<< "a null-union Welcome was not refused by the handshake";
EXPECT_FALSE(session.Started());
EXPECT_EQ(Client::ClientSession::Active(), nullptr);
// Stop()'s not-started path closes the server FIRST (ClientSession.cpp); a server left
// m_accepted would refuse every later Start in this process.
EXPECT_FALSE(serverSession.Accepted());
EXPECT_EQ(Server::ServerSession::Active(), nullptr);
const std::string delta = ReadLog().substr(before.size());
EXPECT_TRUE(Contains(delta,
"MG_Remote client: the server's first control frame is not a verifiable "
"Welcome"))
<< "Start refused, but not with the guard's own line. Log delta:\n"
<< delta;
EXPECT_FALSE(Contains(delta, "Fatal{AbiMismatch"))
<< "the null-union Welcome reached the fingerprint compare. Log delta:\n"
<< delta;
// And the server's half of the handshake DID run - the Hello it received was this
// client's real one - so the case drove Start past the point a stub would stop at.
EXPECT_TRUE(Contains(delta, "MG_Remote server: accepted with NO backend"))
<< "ServerSession::Accept never ran, so the Welcome guard was not reached the way "
"Start reaches it. Log delta:\n"
<< delta;
}
int main(int argc, char** argv) {
// Before anything logs: MG_Util::Debug::InitFile() reads the variable once, on the first
// write, and caches the FILE*. The name carries this process's pid, because
// gtest_discover_tests runs every case as its own process, in parallel under ctest -j.
namespace fs = std::filesystem;
const fs::path path = fs::temp_directory_path() /
("mobilegl-sessionhandshake-test-" + std::to_string(ProcessId()) + ".log");
std::error_code ec;
fs::remove(path, ec);
g_logPath = path.string();
#if defined(_WIN32)
_putenv_s("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str());
#else
setenv("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str(), 1);
#endif
::testing::InitGoogleTest(&argc, argv);
const int rc = RUN_ALL_TESTS();
fs::remove(path, ec);
return rc;
}
+232 -33
View File
@@ -198,7 +198,8 @@ TEST(SessionTest, TheDefaultGeometryIsTheFourContractRingSizes) {
ASSERT_EQ(segments.Create(SessionSegmentSizes{}, MemoryRole::Server), MOBILEGL_OK);
EXPECT_EQ(segments.CmdRingCapacity(), 8ull * 1024 * 1024);
EXPECT_EQ(segments.StageBytes(), 32ull * 1024 * 1024);
EXPECT_EQ(segments.ReplyBytes(), 8ull * 1024 * 1024);
// ID-47: 16 MiB, eight slots of 2 MiB. ProtocolSmokeTest pins the same number on the wire.
EXPECT_EQ(segments.ReplyBytes(), 16ull * 1024 * 1024);
EXPECT_EQ(segments.EventRingCapacity(), 256ull * 1024);
EXPECT_EQ(segments.AnnouncedSize(SessionSegmentSlot::Cmd),
@@ -206,12 +207,60 @@ TEST(SessionTest, TheDefaultGeometryIsTheFourContractRingSizes) {
// SEG_STAGE is not a ring at all - no control page, no cursor triple, no power-of-two
// rounding - and neither is SEG_REPLY, so both announce exactly what was asked for.
EXPECT_EQ(segments.AnnouncedSize(SessionSegmentSlot::Stage), 32ull * 1024 * 1024);
EXPECT_EQ(segments.AnnouncedSize(SessionSegmentSlot::Reply), 8ull * 1024 * 1024);
EXPECT_EQ(segments.AnnouncedSize(SessionSegmentSlot::Reply), 16ull * 1024 * 1024);
EXPECT_EQ(segments.AnnouncedSize(SessionSegmentSlot::Event),
256ull * 1024 + sizeof(RingControl));
segments.Close();
}
// ID-47 (ID-46 finding 2). The DEFAULT reply pool must hold the largest P5 read: the E2 retrace
// harness snapshots OpenRA's whole 640x480 surface as GL_RGBA/GL_UNSIGNED_BYTE through the
// interposer, 1,228,800 bytes, and the previous geometry (8 MiB / 8 slots, 1 MiB minus a 16-byte
// header) refused it by 180,240 bytes - and refused the 512x512 RGBA8 read s1-v1.md:134 claimed
// it covered, by sixteen. This is the verifier's case, committed: it posts exactly 640*480*4
// bytes into a pool built from SessionSegmentSizes{} and reads them back. RED ONCE by reverting
// SessionSegmentSizes::ReplyBytes to 8 MiB: Post then takes its oversize-abort branch and the
// case dies, which is the verifier's original outcome. That perturbation was run.
TEST(SessionTest, TheDefaultReplyGeometryHoldsTheLargestP5Read) {
SessionSegments segments;
ASSERT_EQ(segments.Create(SessionSegmentSizes{}, MemoryRole::Server), MOBILEGL_OK);
ReplySlotPool pool(segments.ReplyBase(), segments.ReplyBytes(), segments.ReplySlotCount());
ASSERT_TRUE(pool.Valid());
EXPECT_EQ(pool.SlotCount(), 8u);
EXPECT_EQ(pool.SlotBytes(), 2u * 1024 * 1024);
EXPECT_EQ(pool.MaxReplyBytes(), 2u * 1024 * 1024 - 16u);
const std::uint64_t kOpenRaSnapshot = 640ull * 480 * 4; // E2's read, the largest in P5
const std::uint64_t kHalfKSquare = 512ull * 512 * 4; // the read s1-v1.md:134 got wrong
EXPECT_TRUE(pool.CanHold(kOpenRaSnapshot));
EXPECT_TRUE(pool.CanHold(kHalfKSquare));
std::vector<std::uint8_t> answer(static_cast<std::size_t>(kOpenRaSnapshot));
for (std::size_t i = 0; i < answer.size(); ++i) {
answer[i] = static_cast<std::uint8_t>(i * 7 + (i >> 12));
}
pool.Post(1, kReplyStatusOk, answer.data(), answer.size());
std::vector<std::uint8_t> back(answer.size(), 0);
std::int32_t status = -1;
std::uint64_t size = 0;
ASSERT_TRUE(pool.Read(1, back.data(), back.size(), &status, &size))
<< "the E2 snapshot does not fit the default reply pool";
EXPECT_EQ(status, kReplyStatusOk);
EXPECT_EQ(size, kOpenRaSnapshot);
EXPECT_EQ(std::memcmp(back.data(), answer.data(), answer.size()), 0);
// And the 512x512 read, in the next slot, so a geometry that only just clears 640x480 by
// some accident of rounding cannot pass this case either.
answer.resize(static_cast<std::size_t>(kHalfKSquare));
pool.Post(2, kReplyStatusOk, answer.data(), answer.size());
back.assign(answer.size(), 0);
ASSERT_TRUE(pool.Read(2, back.data(), back.size(), &status, &size));
EXPECT_EQ(size, kHalfKSquare);
EXPECT_EQ(std::memcmp(back.data(), answer.data(), answer.size()), 0);
segments.Close();
}
// The ledger is per role and DOES double-count under inproc, deliberately: the two roles map
// the same pages here and will not under spawn, so the per-role numbers are what t1 subtracts
// with and a silently deduplicated total would hide exactly that difference.
@@ -229,9 +278,12 @@ TEST(SessionTest, TheMemoryLedgerIsPerRoleAndIsReleasedOnClose) {
const RoleMemorySample sample = SampleRoleMemory(MemoryRole::Client);
EXPECT_EQ(sample.MappedSegmentBytes, clientBefore + mapped);
#if defined(__linux__) || defined(__ANDROID__)
// VmHWM is the PROCESS's high-water mark, so it is the same number for both roles and
// is only meaningful beside the ledger - which is why RoleMemorySample carries both.
// The peak is the PROCESS's, so it is the same number for both roles and is only
// meaningful beside the ledger - which is why RoleMemorySample carries both.
EXPECT_GT(sample.PeakRssBytes, 0u);
// Holds BY CONSTRUCTION now (the ledger's own running max, RoleMemory.h), not by
// the kernel's grace: GitHub run 35079459114 failed exactly this line with VmHWM
// 4,784,128 < VmRSS 4,849,664. The control on the construction is the next case.
EXPECT_GE(sample.PeakRssBytes, sample.CurrentRssBytes);
#endif
}
@@ -239,6 +291,50 @@ TEST(SessionTest, TheMemoryLedgerIsPerRoleAndIsReleasedOnClose) {
EXPECT_EQ(LedgerMappedBytes(MemoryRole::Server), serverBefore);
}
// The kernel's VmHWM is NOT a monotone bound on the kernel's VmRSS at read time: hiwater_rss
// is stored only when RSS is about to drop, task_mem() reports max(stored, rss-now), and the
// wave-1 sampler read the two keys in two passes, so the second fopen could grow RSS past the
// peak the first pass reported. GitHub run 35079459114 (ubuntu-24.04) caught it; the probe under
// ~/w7/p5-s1-probe reproduced it locally. The rule is therefore that the ledger keeps ITS OWN
// running peak and folds the kernel's two numbers into it, so a stubbed reader whose current
// exceeds its peak must not fail. RED ONCE by reverting SampleRoleMemoryInto to
// `sample.PeakRssBytes = kernelPeakRssBytes;` - the first EXPECT_EQ below then reads 100 against
// 200 and the EXPECT_GE beside it is the CI line again. That perturbation was run.
TEST(SessionTest, TheLedgersPeakIsItsOwnRunningMaxAndNeverTheKernelsHighWaterMarkVerbatim) {
std::atomic<std::uint64_t> runningPeak{0};
// The CI shape: the kernel says peak 100, current 200.
RoleMemorySample sample = SampleRoleMemoryInto(runningPeak, MemoryRole::Client, 100, 200);
EXPECT_EQ(sample.PeakRssBytes, 200u)
<< "the kernel's peak was reported verbatim although its current exceeded it";
EXPECT_GE(sample.PeakRssBytes, sample.CurrentRssBytes);
EXPECT_EQ(sample.CurrentRssBytes, 200u);
EXPECT_EQ(runningPeak.load(), 200u);
// A later, smaller sample does not lower it: a running max never decreases.
sample = SampleRoleMemoryInto(runningPeak, MemoryRole::Server, 150, 120);
EXPECT_EQ(sample.PeakRssBytes, 200u);
EXPECT_EQ(sample.CurrentRssBytes, 120u);
EXPECT_EQ(sample.Role, MemoryRole::Server);
// The kernel's peak still counts when it IS the larger number: it is a lower bound on
// the true peak that this process's own samples may have missed.
sample = SampleRoleMemoryInto(runningPeak, MemoryRole::Client, 300, 100);
EXPECT_EQ(sample.PeakRssBytes, 300u);
EXPECT_EQ(runningPeak.load(), 300u);
// And the production sampler reads BOTH keys in ONE pass, so the pair it folds is one
// snapshot: on Linux neither number is 0 and the pair is self-consistent.
#if defined(__linux__) || defined(__ANDROID__)
std::uint64_t kernelPeak = 0;
std::uint64_t kernelCurrent = 0;
ProcessRssBytes(&kernelPeak, &kernelCurrent);
EXPECT_GT(kernelPeak, 0u);
EXPECT_GT(kernelCurrent, 0u);
EXPECT_GE(kernelPeak, kernelCurrent) << "one pass over /proc/self/status disagreed with itself";
#endif
}
// ---------------------------------------------------------------------------
// Two real threads, twenty thousand records
// ---------------------------------------------------------------------------
@@ -571,14 +667,72 @@ TEST(SessionTest, DeclinedIsARealAnswerWithNoPayload) {
#if defined(GTEST_HAS_DEATH_TEST) && GTEST_HAS_DEATH_TEST
// A reply larger than a slot is FATAL, not chunked and not truncated: P5's only large answer is
// a blocking ReadPixels whose size the client knows before it emits, so an overflow means the
// two sides disagree about the frame. A gate that cannot go red is not a gate.
// two sides disagree about the frame. A gate that cannot go red is not a gate - and a death
// control with an empty regex is not a gate either (ID-46 finding 10: a bare std::abort(), or a
// segfault in a broken refusal path, satisfied the previous `""`). The regex below is the
// diagnostic's own wording, which WireLogFatal echoes to stderr for exactly this reader. RED ONCE
// by replacing the oversize branch's WireLogFatal with a bare std::abort(): the process still
// dies, the regex finds nothing, and the case fails on "died but not with the expected error".
// That perturbation was run. The boundary is a pair: exactly MaxReplyBytes() posts and reads
// back, one more byte is the named refusal.
TEST(SessionTestDeath, AReplyLargerThanItsSlotIsFatalRatherThanTruncated) {
SessionSegments segments;
ASSERT_EQ(segments.Create(TestSizes(), MemoryRole::Server), MOBILEGL_OK);
ReplySlotPool pool(segments.ReplyBase(), segments.ReplyBytes(), segments.ReplySlotCount());
ASSERT_TRUE(pool.Valid());
std::vector<std::uint8_t> oversize(pool.SlotBytes() + 1, 0xAB);
EXPECT_DEATH(pool.Post(1, kReplyStatusOk, oversize.data(), oversize.size()), "");
ASSERT_EQ(pool.SlotBytes(), 8192u);
ASSERT_EQ(pool.MaxReplyBytes(), 8176u);
std::vector<std::uint8_t> exact(pool.MaxReplyBytes(), 0xAB);
EXPECT_TRUE(pool.CanHold(exact.size()));
pool.Post(1, kReplyStatusOk, exact.data(), exact.size());
std::vector<std::uint8_t> back(exact.size(), 0);
std::uint64_t size = 0;
ASSERT_TRUE(pool.Read(1, back.data(), back.size(), nullptr, &size));
EXPECT_EQ(size, exact.size());
std::vector<std::uint8_t> oversize(pool.MaxReplyBytes() + 1, 0xAB);
EXPECT_FALSE(pool.CanHold(oversize.size()));
EXPECT_DEATH(pool.Post(2, kReplyStatusOk, oversize.data(), oversize.size()),
"reply pool: Fatal\\{ProtocolCorruption\\} - a 8177 byte answer for seq 2 does not "
"fit a 8192 byte slot \\(payload cap 8176\\)");
}
// ID-47's client half, on the DEFAULT geometry so the numbers are the ruling's: exactly
// MaxReplyBytes() = 2,097,136 passes, one more byte is refused BY NAME before emission, and the
// 1024x512 RGBA8 read - exactly 2 MiB, sixteen bytes over the cap, the same sixteen-byte shape
// that sank the previous geometry's 512x512 claim - is refused with its own dimensions in the
// line. The message is the contract's verbatim: `ReadPixels <w>x<h> <format> <bytes> > <cap>`.
// RED ONCE by replacing RequireReadPixelsFits's WireLogFatal with a bare std::abort() (the two
// death regexes then match nothing) and, separately, by making CanHold `<` instead of `<=` (the
// exact-cap call then dies). Both perturbations were run.
TEST(SessionTestDeath, AReadPixelsLargerThanAReplySlotIsRefusedAtTheClientByName) {
SessionSegments segments;
ASSERT_EQ(segments.Create(SessionSegmentSizes{}, MemoryRole::Server), MOBILEGL_OK);
ReplySlotPool pool(segments.ReplyBase(), segments.ReplyBytes(), segments.ReplySlotCount());
ASSERT_TRUE(pool.Valid());
const std::uint64_t cap = pool.MaxReplyBytes();
ASSERT_EQ(cap, 2097136u);
constexpr std::uint32_t kGlRgba = 0x1908;
constexpr std::uint32_t kGlUnsignedByte = 0x1401;
// Exactly the cap: 524,284 RGBA8 pixels in one row. Returns, no death.
ASSERT_EQ(524284ull * 1 * 4, cap);
EXPECT_TRUE(pool.CanHold(cap));
pool.RequireReadPixelsFits(524284, 1, kGlRgba, kGlUnsignedByte, cap);
// One more byte.
EXPECT_FALSE(pool.CanHold(cap + 1));
EXPECT_DEATH(pool.RequireReadPixelsFits(524284, 1, kGlRgba, kGlUnsignedByte, cap + 1),
"Fatal\\{ReplyTooLarge, \"ReadPixels 524284x1 0x1908/0x1401 2097137 > 2097136\"\\}");
// The read a caller would actually make: 1024x512 RGBA8 = 2 MiB, 16 over.
EXPECT_DEATH(pool.RequireReadPixelsFits(1024, 512, kGlRgba, kGlUnsignedByte, 1024ull * 512 * 4),
"Fatal\\{ReplyTooLarge, \"ReadPixels 1024x512 0x1908/0x1401 2097152 > 2097136\"\\}");
// And E2's read, the reason for the geometry, is not refused.
pool.RequireReadPixelsFits(640, 480, kGlRgba, kGlUnsignedByte, 640ull * 480 * 4);
segments.Close();
}
#endif
@@ -766,27 +920,16 @@ TEST(SessionTest, ShutdownUnparksTheApplyThreadAndTheJoinIsBounded) {
}
// ---------------------------------------------------------------------------
// The ABI fingerprint's mixer
// The ABI fingerprint's mixer: MOVED to SessionHandshakeTest (ID-46 finding 6).
//
// The case that lived here drove MixAbiFingerprint with made-up sizes and never touched
// CapsAbiFingerprint(), the value the two handshakes actually compare - which had its own
// second FNV loop and no caller of the mixer at all, so `return 1;` in production left the
// whole unit lane green. The sensitivity case now starts from CapsAbiFingerprint(), which
// needs CapsCodec.h and therefore the GL frontend's umbrella header that this suite keeps
// out; it lives in SessionHandshakeTest.cpp beside the two handshake-guard controls.
// ---------------------------------------------------------------------------
// A fingerprint that cannot be SHOWN to change is indistinguishable from one that is never
// compared, which is why the mixer takes its sizes as arguments instead of reading sizeof
// directly: a test can vary one byte and prove the answer moves.
TEST(SessionTest, TheAbiFingerprintChangesWhenAnyOfItsInputsDoes) {
const std::uint64_t base = MixAbiFingerprint(1024, 1080, 552, 0x00010000, "abc1234");
EXPECT_NE(base, 0u) << "0 is reserved for \"not stated\"";
EXPECT_EQ(base, MixAbiFingerprint(1024, 1080, 552, 0x00010000, "abc1234"));
EXPECT_NE(base, MixAbiFingerprint(1025, 1080, 552, 0x00010000, "abc1234"));
EXPECT_NE(base, MixAbiFingerprint(1024, 1081, 552, 0x00010000, "abc1234"));
EXPECT_NE(base, MixAbiFingerprint(1024, 1080, 553, 0x00010000, "abc1234"));
EXPECT_NE(base, MixAbiFingerprint(1024, 1080, 552, 0x00010001, "abc1234"));
EXPECT_NE(base, MixAbiFingerprint(1024, 1080, 552, 0x00010000, "abc1235"));
// A missing stamp is not the same as an empty one, and neither is the same as a real build.
EXPECT_NE(MixAbiFingerprint(1024, 1080, 552, 0x00010000, nullptr),
MixAbiFingerprint(1024, 1080, 552, 0x00010000, "abc1234"));
}
// ---------------------------------------------------------------------------
// Fix round 1 - the cases the adversarial review's findings earned
// ---------------------------------------------------------------------------
@@ -796,13 +939,19 @@ TEST(SessionTest, TheAbiFingerprintChangesWhenAnyOfItsInputsDoes) {
// appliedSeq is behind, every later advance is a no-op for ever and every WaitForApplied on
// kWaitForever - the verb barrier and every reply wait - blocks permanently. A hang whose only
// evidence is one ERROR line is not a diagnosis.
//
// The regex names the diagnostic (ID-46 finding 10; see the reply-pool death control for why an
// empty one was not a gate). RED ONCE by replacing AdvanceMonotonic's WireLogFatal block with a
// bare std::abort(): the case then fails on "died but not with the expected error". That
// perturbation was run.
#if defined(GTEST_HAS_DEATH_TEST) && GTEST_HAS_DEATH_TEST
TEST(SessionTestDeath, AWatermarkThatMovesBackwardsIsFatalRatherThanIgnored) {
alignas(4096) RingControl control{};
InitRingControl(control);
Watermark::AdvanceApplied(control, 10);
ASSERT_EQ(control.appliedSeq.load(), 10u);
EXPECT_DEATH(Watermark::AdvanceApplied(control, 9), "");
EXPECT_DEATH(Watermark::AdvanceApplied(control, 9),
"Fatal\\{ProtocolCorruption, \"watermark\"\\} appliedSeq moved backwards, 10 -> 9");
}
#endif
@@ -925,10 +1074,17 @@ TEST(SessionTest, TheProducerRemembersWhatItPublishedEvenIfTheWatermarkLags) {
// M-3. FlatBuffers' Verifier::VerifyTable is `return !table || table->Verify(*this)`, so a NULL
// union member PASSES verification: a 24-byte frame verifies, carries the file identifier,
// reports msg_type() == Hello, and returns nullptr from msg_as_Hello(). Both handshakes now
// fold that into their guard instead of dereferencing it. This case is the proof the shape is
// reachable at all, so the guard cannot be "simplified" away later.
TEST(SessionTest, AVerifiableFrameCanCarryANullUnionPayload) {
// reports msg_type() == Hello, and returns nullptr from msg_as_Hello(). Both handshakes fold
// that into their guard instead of dereferencing it.
//
// THIS CASE IS THE PREMISE, NOT THE CONTROL. It proves the shape is reachable - a FlatBuffers
// property - and nothing about MobileGL: the wave-1 review (ID-46 finding 7) deleted both
// `msg_as_*() == nullptr` clauses and this case stayed green, because it never calls Accept or
// Start. The controls on the two guards are SessionHandshakeTest's, which drive this exact
// frame THROUGH ServerSession::Accept and ClientSession::StartOverTransportPair and require
// each guard's own refusal line. s1-v1.md:338 claimed this case meant "the guard cannot be
// simplified away"; it did not, and s1-v3.md says so.
TEST(SessionTest, ANullUnionFrameVerifiesWhichIsThePremiseOfBothHandshakeGuards) {
::flatbuffers::FlatBufferBuilder builder(256);
auto envelope = ::MobileGL::Wire::CreateCtrlEnvelope(builder, ::MobileGL::Wire::CtrlMsg::Hello,
::flatbuffers::Offset<void>());
@@ -1028,26 +1184,69 @@ TEST(SessionTest, TheStageCursorTripleStaysDeadAcrossAWholeSession) {
// have every var-tail record SKIPPED by Pop, silently, with the record lost and nothing logged on
// either side. Pop now requires a filler to carry both the flag AND kind == kRingPadRecordKind, so
// a real record wearing that bit is delivered instead of eaten.
//
// THE BIT HAS TO BE ON THE WIRE. The first version of this case asked Reserve(7, kRecPad, 16),
// and RingProducer::Reserve MASKS kRecPad OUT of whatever the caller passes (Ring.cpp: `flags &
// ~kRecPad`), so the header it stored had flags == 0, Pop's pad arm was never entered, and the
// case stayed green with the kind check deleted (ID-46 finding 5, executed by the verifier). So
// this producer writes the header ITSELF after Reserve, the way a codec with its own header
// struct does - MGPWireRecHeader and RingRecordHeader are the same eight bytes - which is the one
// path Reserve's mask cannot cover. RingTest.TheTwoFlagSpacesAreDisjointByTranslation (ID-43) is
// the same control at the ring; this one is at the SESSION, where what is pinned in addition is
// that appliedSeq counts the delivered record and does NOT count the genuine filler beside it.
// RED ONCE by deleting `&& header.kind == kRingPadRecordKind` from RingConsumer::Pop: the record
// vanishes into the wrap-filler skip, `seen` stays 0 and appliedSeq stays 0, and the case fails
// on the first EXPECT's message. That perturbation was run.
TEST(SessionTest, ARecordWearingThePadBitIsDeliveredRatherThanEatenAsAFiller) {
SessionFixture session;
ASSERT_TRUE(session.Build(TestSizes()));
// kRecPad is MGPipeCallFlags::kVarTail's bit. Kind 7 is a real opcode, not a filler.
void* payload = session.cmdProducer.Reserve(7, kRecPad, 16);
void* payload = session.cmdProducer.Reserve(7, kRecNone, 16);
ASSERT_NE(payload, nullptr);
std::memset(payload, 0xAB, 16);
// Stamp the bit past Reserve's mask, exactly where the codec's header struct would put it.
auto* headerBytes = static_cast<std::uint8_t*>(payload) - sizeof(RingRecordHeader);
RingRecordHeader stamped{};
std::memcpy(&stamped, headerBytes, sizeof(stamped));
ASSERT_EQ(stamped.kind, 7u);
ASSERT_EQ(stamped.flags & kRecPad, 0u) << "Reserve stopped masking kRecPad";
stamped.flags = static_cast<std::uint16_t>(stamped.flags | kRecPad);
std::memcpy(headerBytes, &stamped, sizeof(stamped));
session.producer.PublishAndNotify(1);
int seen = 0;
std::uint16_t seenKind = 0;
std::uint16_t seenFlags = 0;
while (session.consumer.ApplyOne([&](const RingRecordView& view) {
seenKind = view.kind;
seenFlags = view.flags;
++seen;
})) {
}
EXPECT_EQ(seen, 1) << "the record was skipped as a wrap filler because it wore bit 2";
EXPECT_EQ(seen, 1) << "the record was skipped as a wrap filler because it wore bit 2 - Pop's "
"kind check (a filler is kRecPad AND kind == kRingPadRecordKind) is the "
"only thing between a stamped header and a silently deleted record";
EXPECT_EQ(seenKind, 7);
EXPECT_NE(seenFlags & kRecPad, 0u)
<< "the bit arrives intact: the kind check narrows the SKIP, it does not scrub the bit";
EXPECT_EQ(session.Control().appliedSeq.load(), 1u);
// The opposite control, so that "delivered" cannot be satisfied by not skipping anything:
// a header wearing kRecPad whose kind IS kRingPadRecordKind is a genuine wrap filler, still
// vanishes, and is NOT counted by appliedSeq (R-9: a filler does not advance seq).
void* filler = session.cmdProducer.Reserve(kRingPadRecordKind, kRecNone, 16);
ASSERT_NE(filler, nullptr);
headerBytes = static_cast<std::uint8_t*>(filler) - sizeof(RingRecordHeader);
std::memcpy(&stamped, headerBytes, sizeof(stamped));
stamped.flags = static_cast<std::uint16_t>(stamped.flags | kRecPad);
std::memcpy(headerBytes, &stamped, sizeof(stamped));
session.producer.PublishAndNotify(2);
while (session.consumer.ApplyOne([&](const RingRecordView&) { ++seen; })) {
}
EXPECT_EQ(seen, 1) << "a genuine filler was delivered as a record: Pop's skip was removed, "
"not narrowed";
EXPECT_EQ(session.Control().appliedSeq.load(), 1u) << "a filler advanced appliedSeq (R-9)";
}
// The kHostSpan/kRecBorrowSlot collision, made loud. P5 implements no borrowed slots and is ruled
+106
View File
@@ -0,0 +1,106 @@
#!/bin/bash
# R-16 FOR THE CI NEGATIVE CONTROLS THEMSELVES: a control-run smoke test.
#
# BRIEF-P5 13 (R-16) says a negative control must assert its own failure reason and that every gate
# carries a line saying "I made it red once, by doing X". The two controls this file exercises ARE
# gates, and until ID-46 finding 8 nobody could make either of them red, because a workflow `run:`
# block only executes on a runner. The wave-1 verification agent had to hand-copy the blocks into
# throwaway harnesses to show they were broken (wave1-codex-verify.md 8). This file is that
# experiment, kept: it runs the REAL control scripts - the same files .github/workflows/test.yml
# invokes, not copies of them - against a stubbed ctest, and checks that each one passes exactly
# when it should.
#
# The case that matters is the first one. A stubbed ctest reports a NON-EMPTY selection and then
# fails with UNRELATED_CONTROL_FAILURE: a reason that has nothing to do with the knob the control
# turns. Before ID-48's fix both controls printed their success message and the step exited 0. They
# must now report FAILED.
#
# usage: control_smoke_test.sh
set -u
HERE="$(cd "$(dirname "$0")" && pwd)"
WORK="$(mktemp -d)"
trap 'rm -rf "${WORK}"' EXIT
STUB_DIR="${WORK}/stub"
mkdir -p "${STUB_DIR}"
cp "${HERE}/testdata/stub_ctest.sh" "${STUB_DIR}/ctest"
chmod +x "${STUB_DIR}/ctest"
passes=0
failures=0
# expect <expected: PASSED|FAILED> <label> -- <command...>
expect() {
want="$1"; label="$2"; shift 3 # shift past the literal "--"
outfile="${WORK}/run.out"
"$@" > "${outfile}" 2>&1
rc=$?
if [ "${rc}" -eq 0 ]; then got="PASSED"; else got="FAILED"; fi
if [ "${got}" = "${want}" ]; then
passes=$((passes + 1))
printf 'ok %-58s %s (rc=%d)\n' "${label}" "${got}" "${rc}"
else
failures=$((failures + 1))
printf 'NOT OK %-58s expected %s, got %s (rc=%d)\n' "${label}" "${want}" "${got}" "${rc}"
sed 's/^/ | /' "${outfile}"
fi
}
run_split() { # $1 = STUB_MODE
env -i PATH="${STUB_DIR}:/usr/bin:/bin" STUB_MODE="$1" \
CTEST=ctest CONTROL_TMPDIR="${WORK}/tmp-$1" \
bash "${HERE}/split_negative_controls.sh"
}
run_retrace() { # $1 = STUB_MODE
cd "${WORK}" || return 127
mkdir -p "${WORK}/OpenRA"
env -i PATH="${STUB_DIR}:/usr/bin:/bin" STUB_MODE="$1" \
CTEST=ctest CONTROL_TMPDIR="${WORK}/tmp-$1" \
PULL_LIBRARY="${WORK}/pull.so" FROZEN_LIBRARY="${WORK}/frozen.so" \
bash "${HERE}/retrace_pull_library_control.sh" OpenRA DirectGLES
}
echo "=== the split lane's E1 / E3(a) controls (scripts/ci/split_negative_controls.sh)"
# THE FINDING, REPRODUCED. Non-empty selection, green baseline, and a red that is not the knob's.
expect FAILED "unrelated failure with a non-empty selection" -- run_split unrelated
# ... and the same control on the same stub, failing for its own reason: it must PASS.
expect PASSED "the scenarios' own diagnostic" -- run_split evidence
# The pre-existing half of the control, which was never broken: a knob that reds nothing.
expect FAILED "the knob leaves the selection green" -- run_split green
# The arming counter's half of the finding: a baseline that is already red cannot arm anything.
expect FAILED "the baseline is already red" -- run_split red-baseline
# The disarmed lane, which is a legitimate exit 0 while c1/s1/v1 are landing.
expect PASSED "every split entry skipped (lane not armed)" -- run_split all-skipped
echo
echo "=== the retrace lane's pull-library control (scripts/ci/retrace_pull_library_control.sh)"
# A pull-shaped library the nm identity check accepts: a real ELF .so defining no MG_Remote symbol.
if command -v cc > /dev/null 2>&1; then
printf '%s\n' 'int mobilegl_pull_only(void) { return 1; }' > "${WORK}/pull.c"
cc -shared -fPIC -o "${WORK}/pull.so" "${WORK}/pull.c" || { echo "cannot build the stand-in library"; exit 1; }
else
echo "no cc available; the retrace half of this smoke test needs one" >&2
exit 1
fi
: > "${WORK}/frozen.so"
# THE FINDING, part (b): a regex matching no tests. --no-tests=error exits non-zero and the old
# control read that as "the pull library turned it red".
expect FAILED "empty selection (--no-tests=error exit)" -- run_retrace retrace-noselect
# A red that never names the transport: a fixture failure, a loader failure, a timeout.
expect FAILED "red without the transport-resolution message" -- run_retrace retrace-unrelated
# The real thing - and note the stub emits it CMake-wrapped across two lines, which a line-oriented
# grep for the literal sentence would miss.
expect PASSED "run_trace_case.cmake's own sentence, wrapped" -- run_retrace retrace-evidence
# The pull library replaying green is the failure this control exists to catch.
expect FAILED "a pull library passed the split retrace" -- run_retrace retrace-green
echo
echo "smoke test: ${passes} passed, ${failures} failed"
if [ "${failures}" -gt 0 ]; then
echo "CONTROL_SMOKE_TEST_FAILED"
exit 1
fi
echo "CONTROL_SMOKE_TEST_OK"
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Tally a ctest --output-junit file as "passed failed skipped".
Split out of .github/workflows/test.yml's negative-control step so that the workflow, the local
gate and the control smoke test all count a run the same way.
WHY THIS EXISTS AT ALL (ID-46 finding 8, second half). The counter this replaces lived inline in
the workflow and counted a case as having "run" when it was merely not <skipped/>:
if case.find('skipped') is None and case.get('status') not in ('notrun', 'disabled'):
ran += 1
so a case that RAN AND FAILED armed the negative controls below it. Combined with the `|| true`
that hid the baseline's exit code, a lane in which every split entry was already red reported
itself armed, and a control that turns an already-red entry red then "passed". Passed, failed and
skipped are three different answers and the caller needs all three.
"""
import sys
import xml.etree.ElementTree as ET
def tally(path):
passed = failed = skipped = 0
for case in ET.parse(path).getroot().iter('testcase'):
if case.find('failure') is not None or case.find('error') is not None:
failed += 1
elif case.find('skipped') is not None or case.get('status') in ('notrun', 'disabled'):
skipped += 1
else:
passed += 1
return passed, failed, skipped
def main():
if len(sys.argv) != 2:
print("usage: junit_tally.py <junit.xml>", file=sys.stderr)
return 2
try:
passed, failed, skipped = tally(sys.argv[1])
except Exception as exc: # a malformed file is not "zero of everything"
print(f"junit_tally: cannot parse {sys.argv[1]}: {exc}", file=sys.stderr)
return 1
print(f"{passed} {failed} {skipped}")
return 0
if __name__ == '__main__':
raise SystemExit(main())
+71
View File
@@ -0,0 +1,71 @@
#!/bin/bash
# R-16's "I made it red once, by doing X" for scripts/ci/control_smoke_test.sh, mechanised so the
# claim can be re-checked rather than believed.
#
# X = revert the message check in each control, i.e. put the controls back in the state ID-46
# finding 8 found them in: a non-zero ctest exit is accepted whatever the failure was.
#
# The smoke test must then FAIL, and it must fail on the two cases that exist for this defect -
# "unrelated failure with a non-empty selection" and "red without the transport-resolution
# message" - and not merely somewhere. A smoke test that goes red for any other reason when the
# evidence check is removed would not be pinning the evidence check.
set -u
HERE="$(cd "$(dirname "$0")" && pwd)"
WORK="$(mktemp -d)"
trap 'cp "${WORK}/split.orig" "${HERE}/split_negative_controls.sh"; cp "${WORK}/retrace.orig" "${HERE}/retrace_pull_library_control.sh"; rm -rf "${WORK}"' EXIT
cp "${HERE}/split_negative_controls.sh" "${WORK}/split.orig"
cp "${HERE}/retrace_pull_library_control.sh" "${WORK}/retrace.orig"
echo "=== baseline: the smoke test must be GREEN before anything is perturbed"
if ! bash "${HERE}/control_smoke_test.sh" > "${WORK}/before.log" 2>&1; then
echo "the smoke test is ALREADY RED; the red-check below would prove nothing"
cat "${WORK}/before.log"
exit 1
fi
tail -1 "${WORK}/before.log"
echo
echo "=== perturbation: remove the evidence check from both controls"
python3 - "${HERE}/split_negative_controls.sh" "${HERE}/retrace_pull_library_control.sh" <<'PY'
import sys
split, retrace = sys.argv[1], sys.argv[2]
for path, needle in ((split, 'grep -qE "${evidence}"'), (retrace, 'grep -qF "${EVIDENCE}"')):
text = open(path).read()
out, hit = [], 0
for line in text.splitlines(keepends=True):
if needle in line and line.lstrip().startswith('if ! '):
indent = line[:len(line) - len(line.lstrip())]
out.append(f"{indent}if false; then\n")
hit += 1
else:
out.append(line)
if hit != 1:
raise SystemExit(f"expected exactly one evidence check in {path}, found {hit}")
open(path, 'w').write(''.join(out))
print("both evidence checks reverted to 'any non-zero ctest exit is accepted'")
PY
echo
echo "=== the smoke test on the reverted controls (it MUST be red, on those two cases)"
bash "${HERE}/control_smoke_test.sh" > "${WORK}/after.log" 2>&1
rc=$?
cat "${WORK}/after.log"
if [ "${rc}" -eq 0 ]; then
echo
echo "RED-CHECK FAILED: the controls accept an unrelated failure again and the smoke test still passed."
exit 1
fi
missed=0
grep -q "NOT OK unrelated failure with a non-empty selection" "${WORK}/after.log" || missed=1
grep -q "NOT OK red without the transport-resolution message" "${WORK}/after.log" || missed=1
if [ "${missed}" -ne 0 ]; then
echo
echo "RED-CHECK FAILED: the smoke test went red, but not on the two cases the evidence check exists for."
exit 1
fi
echo
echo "P5_T1_CONTROL_SMOKE_REDCHECK_OK - removing the evidence check reds exactly the two cases that pin it"
+117
View File
@@ -0,0 +1,117 @@
#!/bin/bash
# THE RETRACE-SPLIT LANE'S NEGATIVE CONTROL: a PULL library must red this split retrace.
#
# This file is the body of .github/workflows/test.yml's "Negative control - the PULL library must
# red this split retrace" step, extracted for the reason given at the top of
# scripts/ci/split_negative_controls.sh: a `run:` block is unreviewable and untestable off a
# runner, and scripts/ci/control_smoke_test.sh now runs THIS file rather than a hand-made copy.
#
# WHAT THE CONTROL IS FOR. The retrace-split job replays a trace against the SPLIT runtime under
# MOBILEGL_TRANSPORT=inproc. OpenRA scores ssim 1.000000 against a MONOLITH library too - measured -
# so the picture is not and cannot be this lane's gate. What stands between the job and a green that
# ran monolith end to end is run_trace_case.cmake's transport-resolution assertion
# (run_trace_case.cmake:265-289): the library must have logged
# "MOBILEGL_TRANSPORT=inproc - the MGPipe record stream", which exists only in
# ConfigLoader::InitTransport's InProcess arm, which exists only under MOBILEGL_BUILD_DISAGGREGATED.
# This control swaps the pull library over the frozen path and requires the same replay to fail FOR
# THAT REASON.
#
# WHAT THE REVIEW FOUND (ID-46 finding 8 part (b), CONFIRMED by execution against the REAL ctest in
# a REAL build tree; ID-48 assigns it here). Two holes, both of which let the control pass while
# asserting nothing:
#
# 1. NO SELECTION GUARD AT ALL - unlike the split lane's run_control, which has had one since
# review finding M-4. With a case/backend regex matching no tests, `--no-tests=error` exits 8,
# and the old `if [ "${control_rc}" -eq 0 ]` accepted 8 as "the pull library turned it red".
# The verifier measured exactly that: "real ctest exit for a regex matching NO tests: 8",
# HARNESS_EXIT=0. An empty selection was the one thing this control could not tell apart from
# a working transport-identity assertion.
# 2. ONLY "non-zero ctest" WAS CHECKED after the nm identity check. The nm check establishes that
# the library IS a pull build; it says nothing about why the replay failed. A loader failure, a
# missing fixture or a timeout all passed the control.
#
# Both are closed below: the selection is counted before the run, and the red must carry
# run_trace_case.cmake's own words.
#
# Usage: retrace_pull_library_control.sh <case> <backend>
# CTEST ctest binary (default: ctest)
# CONTROL_TMPDIR scratch dir (default: ${RUNNER_TEMP:-/tmp})
# PULL_LIBRARY the pull libMobileGL.so to swap in
# FROZEN_LIBRARY the path every case has baked in, which PULL_LIBRARY is copied over
set -u
CASE="${1:?usage: retrace_pull_library_control.sh <case> <backend>}"
BACKEND="${2:?usage: retrace_pull_library_control.sh <case> <backend>}"
CTEST="${CTEST:-ctest}"
CONTROL_TMPDIR="${CONTROL_TMPDIR:-${RUNNER_TEMP:-/tmp}}"
PULL_LIBRARY="${PULL_LIBRARY:?PULL_LIBRARY must name the pull build libMobileGL.so}"
FROZEN_LIBRARY="${FROZEN_LIBRARY:?FROZEN_LIBRARY must name the path the cases have baked in}"
mkdir -p "${CONTROL_TMPDIR}"
# run_trace_case.cmake's own sentence for "this library never resolved the transport". Anchored on
# the distinctive clause rather than on the whole paragraph, which carries substituted paths.
EVIDENCE='never reported resolving it'
selector="^MobileGLTraceReplay\.${CASE}\.${BACKEND}$"
# The rerun replays into the same case directory, so the good run's images are put aside and
# restored whichever way the control goes; "Upload actual image" runs `if: always()` and would
# otherwise ship the deliberately-wrong run's output under the good run's name.
GOOD_OUTPUT="${CONTROL_TMPDIR}/split-verified-output"
rm -rf "${GOOD_OUTPUT}"
if [ -d "${CASE}" ]; then cp -a "${CASE}" "${GOOD_OUTPUT}"; fi
restore_good_output() {
if [ -d "${GOOD_OUTPUT}" ]; then
rm -rf "${CASE}"; mv "${GOOD_OUTPUT}" "${CASE}"
echo "restored the verified run's output over the control's"
fi
}
# HOLE 1: COUNT THE SELECTION FIRST. `--no-tests=error` turns an empty selection into a non-zero
# exit, which is indistinguishable from a working control unless the selection is counted.
matched=$("${CTEST}" -N -R "${selector}" | grep -cE '^ *Test *#[0-9]+:')
if [ "${matched}" -lt 1 ]; then
restore_good_output
echo "::error::the control selected ${matched} tests with -R '${selector}', so there is nothing for the pull library to red. --no-tests=error would have exited non-zero on the empty selection and this control used to read that as success (ID-46 finding 8b, measured: ctest exit 8, step green)."
exit 1
fi
# The pull library, unpacked from build-linux's artifact, over the frozen path every case has baked
# in. It defines no MG_Remote symbol, so ConfigLoader has no transport parser and
# MOBILEGL_TRANSPORT=inproc is accepted and ignored - the exact shape of "the split lane ran
# monolith".
cp "${PULL_LIBRARY}" "${FROZEN_LIBRARY}"
if nm --defined-only "${FROZEN_LIBRARY}" | grep -q -i MG_Remote; then
restore_good_output
echo "::error::the control's own library defines MG_Remote symbols, so it is not a pull build and this control would prove nothing"
exit 1
fi
out="${CONTROL_TMPDIR}/retrace-control-output.txt"
export MOBILEGL_TRANSPORT=inproc
"${CTEST}" -V --no-tests=error --timeout 10800 -R "${selector}" > "${out}" 2>&1
control_rc=$?
cat "${out}"
restore_good_output
if [ "${control_rc}" -eq 0 ]; then
echo "::error::a PULL library passed the split retrace. OpenRA scores ssim 1.000000 under a monolith library too (measured), so the picture is not and cannot be this lane's gate - run_trace_case.cmake's transport-resolution assertion is, and it has stopped working. Every green in this job is then a monolith run under a name that says split."
exit 1
fi
# HOLE 2: THE RED MUST BE THIS CONTROL'S RED.
#
# Whitespace is normalised across the WHOLE file before the match, newlines included, because the
# sentence is emitted by CMake's message(FATAL_ERROR ...) and CMake re-wraps that text to its own
# width: "never reported resolving it" arrives split over two lines with a two-space continuation
# indent, and a line-oriented grep for the literal finds nothing. That is not hypothetical - it is
# the shape the stub reproduces in scripts/ci/testdata/stub_ctest.sh.
if ! tr -s '[:space:]' ' ' < "${out}" | grep -qF "${EVIDENCE}"; then
echo "::error::the split retrace went red (ctest exit ${control_rc}) with the pull library in place, but the failure never says the library did not resolve the transport - run_trace_case.cmake's \"${EVIDENCE}\" is absent from the output. A loader failure, a missing fixture, a timeout or an SSIM drop all land here, and none of them establishes that the transport-identity assertion is what caught the pull library. Only 'non-zero ctest' used to be checked (ID-46 finding 8b)."
exit 1
fi
echo "the pull library turned the split retrace red for its own reason (ctest exit ${control_rc}): ${matched} selected case(s) named the transport, not the picture"
+151
View File
@@ -0,0 +1,151 @@
#!/bin/bash
# EXIT GATE E1's NEGATIVE CONTROL and EXIT GATE E3(a)'s.
#
# This file is the body of .github/workflows/test.yml's "Negative controls - the verb barrier and
# the persistent-map push must be load-bearing" step. It lives in the repository rather than inline
# in the workflow for one reason: a workflow `run:` block cannot be executed anywhere except on a
# runner, so the logic below was unreviewable and untestable until it ran in CI - and when the
# wave-1 cross-family review claimed it was broken, confirming the claim needed a hand-made copy of
# these lines with their inputs stubbed (wave1-codex-verify.md 8). A copy is not the thing. The
# smoke test at scripts/ci/control_smoke_test.sh now runs THIS file, so the lines CI executes and
# the lines the smoke test proves are the same lines.
#
# WHAT THE REVIEW FOUND (ID-46 finding 8, CONFIRMED by execution; ID-48 assigns it here).
# The previous version accepted ANY non-zero ctest exit as "the knob is load-bearing". A timeout, a
# setup abort, an unrelated assertion, a harness that died before it read the knob at all - every
# one of them printed "turned N selected entries red, as it must" and the step went green. The
# verifier demonstrated it: a stubbed ctest with a NON-EMPTY selection that failed with
# `UNRELATED_CONTROL_FAILURE` produced both controls' success messages and HARNESS_EXIT=0.
#
# So each control now has to say WHY the red is its own:
#
# 1. THE BASELINE MUST BE GREEN. The arming run below used to end in `|| true` and count every
# case that was not <skipped/> as "ran" - so a case that RAN AND FAILED armed the controls,
# and a control that turns an already-red entry red proves nothing at all. It now counts
# PASSED cases, and a baseline with any failure in it is a hard error rather than an arming
# signal.
# 2. THE RED MUST CARRY THE SELECTED CASE'S OWN FAILURE TEXT. Each control names a regex of the
# diagnostics its scenarios emit when that knob is off, and the red is refused if the output
# carries none of them.
#
# WHY THE EVIDENCE IS THE SCENARIO'S ASSERTION TEXT AND NOT THE KNOB'S OWN LOG LINE.
# ConfigLoader logs a named line for both knobs (ConfigLoader.cpp:385-393, "is the R-1 NEGATIVE
# CONTROL", "is the E3(a) NEGATIVE CONTROL"), and it is tempting to grep for that. It is not
# evidence: it is written at config load, by every process in the run, whatever happens next. A
# setup abort would carry it too. It proves the knob was READ, never that the knob caused the red.
# Only the failing case's own diagnostic does that. (It is also unreachable from here: the three
# DirectGLES.Split. lanes set no MOBILEGL_LOG_FILE_PATH, and the library's console sink is compiled
# out of this configuration, so no MGLOG_ output of any level reaches ctest's transcript. Measured:
# ~/w7/p5-v1-joint-isplit-barrier0.log, 18 aborted entries, zero occurrences of the string "Fatal".)
#
# Usage: split_negative_controls.sh
# CTEST ctest binary (default: ctest)
# CONTROL_TMPDIR scratch dir for the junit + output (default: ${RUNNER_TEMP:-/tmp})
set -u
CTEST="${CTEST:-ctest}"
CONTROL_TMPDIR="${CONTROL_TMPDIR:-${RUNNER_TEMP:-/tmp}}"
mkdir -p "${CONTROL_TMPDIR}"
junit="${CONTROL_TMPDIR}/isplit.xml"
# ---- the baseline ---------------------------------------------------------------------------
#
# THE ARMED STATE IS DERIVED FROM BEHAVIOUR, not from a marker string in the generated ctest files.
# The first version read MGITEST_REMOTE_CLIENT_PRESENT out of *_tests.cmake, which was a
# restatement of the CMake source probe review finding M-1 falsified; the arming condition is a
# runtime fact inside each test process (MG_Config::Transport, ClientSession::Active(),
# ImplementedVerbCount(), read by Harness/SplitRuntimePeek), so the only honest way to ask it from a
# shell is to look at what the entries DID.
"${CTEST}" -L integration-split -j 4 --no-tests=error --output-junit "${junit}"
baseline_rc=$?
if [ ! -f "${junit}" ]; then
echo "::error::the baseline run wrote no ${junit} (ctest exit ${baseline_rc}), so nothing below can tell an armed lane from a broken one"
exit 1
fi
tally=$(python3 "$(dirname "$0")/junit_tally.py" "${junit}")
if [ -z "${tally}" ]; then
echo "::error::could not tally ${junit}; a run whose result cannot be read is not an arming signal"
exit 1
fi
baseline_passed=$(echo "${tally}" | cut -d' ' -f1)
baseline_failed=$(echo "${tally}" | cut -d' ' -f2)
baseline_skipped=$(echo "${tally}" | cut -d' ' -f3)
echo "split entries - passed: ${baseline_passed}, failed: ${baseline_failed}, skipped: ${baseline_skipped} (ctest exit ${baseline_rc})"
# A RED BASELINE DISARMS THE CONTROLS RATHER THAN ARMING THEM (review finding 8, second half).
# `|| true` plus a "not skipped" counter used to treat a case that ran and FAILED as evidence the
# lane was live. Turning an already-red entry red is not a measurement.
if [ "${baseline_failed}" -gt 0 ]; then
echo "::error::${baseline_failed} DirectGLES.Split. entries are ALREADY RED with both knobs at their defaults, so neither negative control below can attribute its red to the knob it turns. Fix the lane first; a control measured against a red baseline is not a control. (This used to be swallowed by an unconditional '|| true' and counted as 'the lane is armed'.)"
exit 1
fi
if [ "${baseline_passed}" -lt 1 ]; then
echo "::warning::every DirectGLES.Split. entry SKIPPED, so neither negative control can fire. The arming condition is a runtime fact - MG_Config::Transport, ClientSession::Active() and ImplementedVerbCount(), read by Harness/SplitRuntimePeek - and it becomes true on the commit that lands the last of c1/s1/v1. This step becomes a gate then, with no edit; it is not a green that asserted anything today."
exit 0
fi
# ---- the controls ---------------------------------------------------------------------------
#
# run_control <name> <filter> <evidence regex> <VAR=VALUE>...
run_control() {
name="$1"; filter="$2"; evidence="$3"; shift 3
matched=$("${CTEST}" -N -L integration-split -R "${filter}" | grep -cE '^ *Test *#[0-9]+:')
if [ "${matched}" -lt 1 ]; then
echo "::error::${name} selected ${matched} tests; its filter no longer matches anything"
exit 1
fi
out="${CONTROL_TMPDIR}/control-output.txt"
env "$@" "${CTEST}" --output-on-failure -L integration-split -R "${filter}" --no-tests=error > "${out}" 2>&1
control_rc=$?
cat "${out}"
if [ "${control_rc}" -eq 0 ]; then
echo "::error::${name} left ${matched} split entries GREEN, so the knob it turns is not load-bearing and the gate it controls proves nothing."
exit 1
fi
# THE HALF THAT WAS MISSING. A non-zero exit is necessary and nowhere near sufficient.
# Whitespace is normalised across the whole file first, for the reason given in
# retrace_pull_library_control.sh: a diagnostic that arrives wrapped is still the diagnostic.
if ! tr -s '[:space:]' ' ' < "${out}" | grep -qE "${evidence}"; then
echo "::error::${name} turned ${matched} selected entries red, but the red carries NONE of the diagnostics those scenarios emit when this knob is off, so it is not this control's red. Required one of: ${evidence}. A timeout, a setup abort, a harness that died before it read the knob, or any unrelated assertion lands here - and every one of them used to print the success message below and leave this step green (ID-46 finding 8). If the entries aborted with no output at all, that is the barrier path having no named diagnostic of its own: see t1-v2.md, it is a debt on the server package, not a reason to accept the red."
exit 1
fi
echo "${name} turned ${matched} selected entries red, and the red carries the scenario's own diagnostic, as it must"
}
# E1: R-1's lockstep verb barrier. Without it the client keeps pulling fields from a live GLContext
# while the server runs ahead, so the server reads future values.
#
# THE SELECTION INCLUDES THE SmallRing LANE, and that is not cosmetic. Measured on v1's joint tree
# (~/w7/p5-v1-joint-isplit-barrier0.log): with the barrier off, every entry the OLD filter selected
# aborted with no output whatsoever, and the one entry in the whole run that failed with a readable
# assertion - ClearThenReadPixelsScenario.cpp:290/:295, reading back 0 where >200 was cleared - was
# a DirectGLES.Split.SmallRing. entry, which the old filter excluded. A control whose selection
# contains no case able to say why it failed cannot assert its own failure reason. The SmallRing
# entries are the same two scenarios under the same transport with SEG_CMD/SEG_STAGE at their floor,
# so including them widens E1's selection strictly within E1's charter.
run_control "negative control E1 (MOBILEGL_IPC_VERB_BARRIER=0)" \
'DirectGLES\.Split\.(SmallRing\.)?(Triangle|ClearThenReadPixels)' \
'ClearThenReadPixelsScenario\.cpp:(290|295)|the bottom band should be red after the resolve|the top band should be blue after the resolve|TriangleScenario\.cpp:[0-9]+: Failure' \
MOBILEGL_IPC_VERB_BARRIER=0
# E3(a): the persistent-map push. 0 is admitted by ConfigLoader on purpose and is documented there
# as this control. The evidence is the scenario's own wording for "the second write never arrived":
# with the push off, the write that no GL call announces cannot reach its draw, which is exactly
# what TwoWritesThroughTheCoherentPointerEachReachTheirOwnDraw and
# AWriteAfterAFrameBoundaryReachesTheNextFramesDraw read back
# (PersistentCoherentMapScenario.cpp:414-417, :442-443). The counting case's pmap= assertion
# (:531-541) is listed too, for the lane where it is the one that runs.
run_control "negative control E3(a) (MOBILEGL_IPC_PERSISTENT_BLOCK_KB=0)" \
'DirectGLES\.Split\.(SmallRing\.)?PersistentCoherentMapScenario' \
"the SECOND write through the same mapping, announced by nothing|frame 1's write through the SAME mapping|cannot have pushed|PersistentCoherentMapScenario\.cpp:[0-9]+: Failure" \
MOBILEGL_IPC_PERSISTENT_BLOCK_KB=0
+119
View File
@@ -0,0 +1,119 @@
#!/bin/bash
# A stubbed `ctest` for scripts/ci/control_smoke_test.sh.
#
# Descended from the verification agent's stub (wave1-codex-verify.md 8, ~/w7/p5-verify-f8-stub/ctest),
# which is what CONFIRMED that the negative controls accepted an unrelated failure. Every mode below
# is deliberately the BEST case for the control under test: the selection is never empty except in
# the mode that exists to test the empty-selection guard, and the baseline is green except in the
# mode that exists to test the red-baseline guard. If a control passes here it is because the
# control's logic is wrong, not because the stub starved it.
#
# STUB_MODE:
# unrelated baseline green; the control's own run fails with UNRELATED_CONTROL_FAILURE
# evidence baseline green; the control's own run fails with the scenarios' own wording
# green baseline green; the control's own run PASSES (the knob is not load-bearing)
# red-baseline the baseline itself has a failed entry
# all-skipped the baseline is entirely skipped (the disarmed lane, a legitimate exit 0)
# retrace-noselect `ctest -N` matches nothing; the run exits 8 the way --no-tests=error does
# retrace-unrelated one match; the run fails without naming the transport
# retrace-evidence one match; the run fails with run_trace_case.cmake's own sentence
# retrace-green one match; the run PASSES
set -u
mode="${STUB_MODE:?STUB_MODE must be set}"
listing=1
junit=""
prev=""
for a in "$@"; do
[ "$a" = "-N" ] && listing_requested=1
if [ "$prev" = "--output-junit" ]; then junit="$a"; fi
prev="$a"
done
listing_requested="${listing_requested:-0}"
emit_listing() {
echo "Test project /stub"
if [ "${mode}" = "retrace-noselect" ]; then
echo "Total Tests: 0"
return
fi
echo " Test #1: DirectGLES.Split.ClearThenReadPixelsScenario.ClearWithNoDrawIsVisibleToDefaultFramebufferReadPixels"
echo "Total Tests: 1"
}
write_junit() {
case "${mode}" in
red-baseline)
body='<testcase name="DirectGLES.Split.TriangleScenario.AVboBackedTriangleReachesReadPixels" status="failed"><failure message="already red"/></testcase>'
;;
all-skipped)
body='<testcase name="DirectGLES.Split.TriangleScenario.AVboBackedTriangleReachesReadPixels" status="notrun"><skipped/></testcase>'
;;
*)
body='<testcase name="DirectGLES.Split.TriangleScenario.AVboBackedTriangleReachesReadPixels" status="run" time="0.3"/>'
;;
esac
printf '%s\n' '<?xml version="1.0" encoding="UTF-8"?>' "<testsuite name=\"stub\">" " ${body}" '</testsuite>' > "$1"
}
if [ "${listing_requested}" = "1" ]; then
emit_listing
exit 0
fi
if [ -n "${junit}" ]; then
write_junit "${junit}"
case "${mode}" in
red-baseline) echo "1/1 Test #1: ... ***Failed"; exit 8 ;;
*) echo "100% tests passed, 0 tests failed out of 1"; exit 0 ;;
esac
fi
# The control's own run.
case "${mode}" in
unrelated)
echo "1/1 Test #1: DirectGLES.Split.ClearThenReadPixelsScenario.ClearWithNoDrawIsVisibleToDefaultFramebufferReadPixels ...***Failed"
echo "UNRELATED_CONTROL_FAILURE: the harness aborted in setup before the knob was read"
exit 8
;;
evidence)
# Both controls' required wording, so one stub serves E1 and E3(a). Copied from the real
# diagnostics: ~/w7/p5-v1-joint-isplit-barrier0.log for the first, and
# PersistentCoherentMapScenario.cpp:414-417 for the second.
echo "1/1 Test #1: DirectGLES.Split.ClearThenReadPixelsScenario.ClearWithNoDrawIsVisibleToDefaultFramebufferReadPixels ...***Failed"
echo "../MobileGL/MG_IntegrationTest/Scenarios/ClearThenReadPixelsScenario.cpp:290: Failure"
echo "Expected: (bottom.r) > (200), actual: '\\0' vs 200"
echo "the SECOND write through the same mapping, announced by nothing: this is exit gate E3(b)"
exit 8
;;
green)
echo "100% tests passed, 0 tests failed out of 1"
exit 0
;;
retrace-noselect)
echo "No tests were found!!!"
exit 8
;;
retrace-unrelated)
echo "1/1 Test #1: MobileGLTraceReplay.OpenRA.DirectGLES ...***Failed"
echo "CMake Error: the fixture could not be unpacked"
exit 8
;;
retrace-evidence)
echo "1/1 Test #1: MobileGLTraceReplay.OpenRA.DirectGLES ...***Failed"
echo "CMake Error at run_trace_case.cmake:279 (message):"
echo " MOBILEGL_TRANSPORT=inproc is set for OpenRA DirectGLES and the library never"
echo " reported resolving it: mobilegl.log carries no"
echo ' "MOBILEGL_TRANSPORT=inproc - the MGPipe record stream".'
exit 8
;;
retrace-green)
echo "100% tests passed, 0 tests failed out of 1"
exit 0
;;
*)
echo "stub_ctest: unknown STUB_MODE '${mode}'" >&2
exit 127
;;
esac
+70 -12
View File
@@ -1389,27 +1389,54 @@ def write(path, text, check, changed):
handle.write(text)
def expect_trip(name, fn):
"""Runs one negative control; a gate that lets it through is the failure."""
def expect_trip(name, because, fn, quiet=False):
"""Runs one negative control; a gate that lets it through, or trips for a reason that is
not ITS OWN, is the failure.
The first version of this function caught any SystemExit - including an unrelated
diagnostic, and even a CLEAN sys.exit(0) - and asked nothing about which one, so the
codex cross-family review's finding 9 could replace a control's callback with either and
the "nine negative controls all trip" line stayed true while the guard it named never
fired. gen_pipe_field_ownership.py's M-1 was the same defect in the sibling generator;
this mirrors that fix.
`because` is a substring the control's OWN message must contain, and an exit code of 0
can never count as a trip."""
try:
fn()
except SystemExit as trip:
print("gen_pipe: self-test %s: tripped as expected (%s)" % (name, str(trip).splitlines()[0][:100]))
return 1
print("gen_pipe: self-test %s: DID NOT TRIP" % name, file=sys.stderr)
code = trip.code
message = str(code) if code is not None else ""
if code != 0 and because in message:
if not quiet:
print("gen_pipe: self-test %s: tripped as expected (%s)"
% (name, message.splitlines()[0][:100]))
return 1
if not quiet:
print("gen_pipe: self-test %s: tripped for SOMEONE ELSE'S reason:\n"
" expected to contain: %s\n"
" actually said (code=%r): %s"
% (name, because, code, message.splitlines()[0][:200] if message else "<empty>"),
file=sys.stderr)
return 0
if not quiet:
print("gen_pipe: self-test %s: DID NOT TRIP" % name, file=sys.stderr)
return 0
def self_test(accessors):
"""The negative controls (check_include_closure.py's shape): each gate must go red for
its reason, and zero trips is itself an error."""
its OWN reason, and zero trips is itself an error."""
canned_struct = "struct Canned {\n Uint32 A;\n Uint32 B, C;\n Uint8 Pad0[3];\n void F() { return; }\n};\n"
controls = [
("struct member without F(...)",
"member(s) with no F(...) in PipeFields.def",
lambda: check_field_lists_cover_struct_members({"Canned": ["A", "B"]}, ["Canned"], [canned_struct])),
("F(...) that is not a member",
"F(...) name(s) that are not members",
lambda: check_field_lists_cover_struct_members({"Canned": ["A", "B", "C", "D"]}, ["Canned"], [canned_struct])),
("payload with no struct",
"struct not found",
lambda: check_field_lists_cover_struct_members({"Nowhere": ["A"]}, ["Nowhere"], [canned_struct])),
]
fill_text = read(os.path.join(PIPE_DIR, "FillPoints.def"))
@@ -1417,16 +1444,24 @@ def self_test(accessors):
field_row = re.compile(r"X\(\s*kDraw\s*,\s*GetBoundVertexArray\s*\)")
if not verb_row.search(fill_text) or not field_row.search(fill_text):
sys.exit("gen_pipe: self-test: FillPoints.def lost the rows the controls edit")
controls.append(("verb missing from FillPoints.def", lambda: parse_fill_points(
controls.append(("verb missing from FillPoints.def",
"GLFunctionsTable member(s) without a verb row",
lambda: parse_fill_points(
accessors, text=verb_row.sub("", fill_text, count=1))))
controls.append(("verb that is not a GLFunctionsTable member", lambda: parse_fill_points(
controls.append(("verb that is not a GLFunctionsTable member",
"verb(s) that are not GLFunctionsTable members",
lambda: parse_fill_points(
accessors, text=verb_row.sub("X(DrawArrays, kDraw) X(NotAVerb, kDraw)", fill_text, count=1))))
controls.append(("field row naming a non-accessor", lambda: parse_fill_points(
controls.append(("field row naming a non-accessor",
"is not an accessor in Coverage.def",
lambda: parse_fill_points(
accessors, text=field_row.sub("X(kDraw, NotAnAccessor)", fill_text, count=1))))
# The EMITTED list's own gate: a row naming a call that is not in PipeCalls.def would
# generate an enumerator nothing can dispatch on.
calls_for_control = parse_calls()
controls.append(("emitted row naming a call that does not exist", lambda: gen_emitted_by(
controls.append(("emitted row naming a call that does not exist",
"which is not a call in PipeCalls.def",
lambda: gen_emitted_by(
[("GetViewport", "SetDynamicState")], calls_for_control, [("GetViewport", "NotACall")])))
# P5 R-13.4's gate. The flags are now a GENERATED TABLE six packages read instead of six
# hard-coded copies, so a token that is not an MGPipeCallFlags enumerator has to stop the
@@ -1434,13 +1469,36 @@ def self_test(accessors):
# kNone, the empty set, may not be OR'd with a real flag and quietly read as one.
flag_typo = Call(1, "Canned", "MGPHandleOnly", "kScreen", ["kHasBlobb"])
flag_kNone = Call(1, "Canned", "MGPHandleOnly", "kScreen", ["kNone", "kHasBlob"])
flag_typo_because = "which is not an MGPipeCallFlags enumerator"
controls.append(("call flag that is not an MGPipeCallFlags enumerator",
flag_typo_because,
lambda: check_call_flags_are_known([flag_typo])))
controls.append(("kNone combined with a real flag",
"combines kNone with",
lambda: check_call_flags_are_known([flag_kNone])))
# R-16 meta-control (codex review finding 9; verified by execution in
# p5-results/wave1-codex-verify.md §9). The exact perturbation there replaced the
# flag-typo control's callback with `sys.exit("unrelated parser failure")` and,
# separately, with `sys.exit(0)`; the OLD expect_trip counted both as "tripped as
# expected" because it never looked at the message or the code. Both must be REJECTED
# here, under the real control's own `because`. This is a quiet, unlisted assertion (not
# one of the nine controls below) so it cannot itself inflate the trip count.
# "Red once by doing X" = reverting expect_trip to `except SystemExit as trip: return 1`
# (accept any SystemExit) - that alone turns each of these two checks from a silent pass
# into the sys.exit below.
if expect_trip("(R-16 meta-control) unrelated diagnostic standing in for the flag-typo guard",
flag_typo_because, lambda: sys.exit("unrelated parser failure"), quiet=True) != 0:
sys.exit("gen_pipe: self-test: expect_trip counted an UNRELATED SystemExit as the "
"flag-typo guard's own trip - finding 9 / R-16 is back")
if expect_trip("(R-16 meta-control) sys.exit(0) standing in for the flag-typo guard",
flag_typo_because, lambda: sys.exit(0), quiet=True) != 0:
sys.exit("gen_pipe: self-test: expect_trip counted sys.exit(0) as a trip - "
"finding 9 / R-16 is back")
trips = 0
for name, fn in controls:
trips += expect_trip(name, fn)
for name, because, fn in controls:
trips += expect_trip(name, because, fn)
# The positive control: the canned struct's exact list passes, and the parser sees the
# padding member as padding and the function as not a member.
check_field_lists_cover_struct_members({"Canned": ["A", "B", "C"]}, ["Canned"], [canned_struct])
+183 -31
View File
@@ -37,7 +37,20 @@
# FlushPendingRangesNow at all, so a gate that hashed only that name protected text the shipping
# build never sees, and a tier-threshold edit made in the ladder that DOES ship would pass it. So
# both names are hashed. The pull build's ladder is compared against the base ref; the push build's
# is compared against a sha pinned at the commit where it was reviewed - see PINNED_FUNCTIONS.
# is compared, ALWAYS AND WHETHER OR NOT <ref-a> DEFINES IT, against a sha pinned at the commit
# where that body was reviewed - see PINNED_FUNCTIONS.
#
# "ALWAYS" IS INTEGRATOR DECISION ID-41 AND IT IS A CHANGE. Until P5 this text said "pinned" while
# the implementation consulted the pin only as a FALLBACK, when <ref-a> did not define the function
# at all. At P3a's own base ref it does not, so the two readings agreed and nobody noticed; from
# ff2994d9 onward it does, so the fallback stopped firing and the row silently went back to being
# ref-a-vs-ref-b. ID-41 rules that the pin is the baseline, because the pin is the REVIEWED text:
# P5 (b1) gave this body two defaulted parameters (hostBaseFrom/hostBaseTo), a
# MOBILEGL_PIPE_VERIFY-only StageSnapshotTooNarrow log and a tier-1 access computation moved into
# InvalidateFlushAccessFor, the pull arm (FlushPendingRangesNow) is byte-identical across that
# change and G1 reports .text +0, so it is the intended change to the eleventh row rather than
# drift - and the answer is to RE-PIN it, not to revert it and not to let the row stop being
# compared. The OTHER TEN stay ref-a-vs-ref-b: nothing about them moved.
#
# THE TENTH IS HERE BY INTEGRATOR DECISION ID-11, resolving a contradiction inside the brief.
# D-F's "Decision: nine functions" table omits FlushPendingRangesNow, but BRIEF-P3A.md:420 calls it
@@ -88,12 +101,22 @@ set -u -o pipefail
SOURCE_PATH=MobileGL/MG_Backend/DirectGLES/Managers.cpp
# The ten that exist at the P3a base ref, so their baseline is read out of <ref-a>.
FUNCTIONS="IsPoolable EnrollIntoPool AcquireFromPool TrimBufferPool ClearBufferPool ProcessDeferredBufferReleases CreateRingStorage RingAvailable RingAllocate FlushPendingRangesNow"
# The ELEVENTH (ID-15), and it is a different kind of row: it was BORN in P3a, so there is no
# body at the base ref to compare it with and its baseline is PINNED below, captured at
# 3e298c9a - the commit at which the two-arm shape was reviewed and accepted.
# The ELEVENTH (ID-15), and it is a different kind of row: it was BORN in P3a, so there was no
# body at the base ref to compare it with, and its baseline is PINNED below and consulted
# UNCONDITIONALLY (ID-41 - see the long note at the top of this file).
#
# THE PIN, AND WHAT RE-PINS IT. Whoever moves this body deliberately replaces BOTH lines and
# writes the decision beside them; a pin with no commit and no decision next to it is a number
# nobody can audit.
# 3e298c9a 37fc94ff... ID-15, P3a: the two-arm shape, reviewed and accepted
# 3dadd4c1 172b0222... ID-41, P5 (b1): [Fix] (DirectGLES): make the extent hostBase is good
# for a parameter of the flush ladder, so tier 1's widening refusal is
# live code the moment a SEG_STAGE snapshot is narrower than the queued
# range. <- CURRENT
PINNED_FUNCTIONS="FlushPendingRangesFrom"
PINNED_BASELINE_REF=3e298c9a
PINNED_SHA_FlushPendingRangesFrom=37fc94ffc5991923d222d585daa3af6511d2352d255623026ce35a3b6963c4a6
PINNED_BASELINE_REF=3dadd4c1
PINNED_BASELINE_DECISION=ID-41
PINNED_SHA_FlushPendingRangesFrom=172b022273db01b16e772d15b269ffcd797fe38c767f7354d83ce113a66040d0
ALL_FUNCTIONS="$FUNCTIONS $PINNED_FUNCTIONS"
EXPECTED_FUNCTION_COUNT=11
# The functions the self-test perturbs, one control each. ClearBufferPool is small, has no forward
@@ -247,7 +270,7 @@ def extract(path, names):
return rows, problems
def perturb(src, dst, names, target):
def perturb(src, dst, names, target, one_token=False):
text = open(src, encoding='utf-8', newline='').read()
masked = mask(text)
hits = find_definition(text, masked, target)
@@ -260,9 +283,17 @@ def perturb(src, dst, names, target):
# perturbation somewhere that is not the body, and the control would be proving the wrong
# thing. Offsets are identical between the two by construction (mask() preserves length).
brace = masked.index('{', begin)
patched = (text[:brace + 1] +
'\n // p3a_untouched_regions.sh --self-test: a body that MOVED.\n' +
text[brace + 1:])
if one_token:
# ONE TOKEN - a single empty statement - and nothing else. ID-41(d) asks the pinned row's
# control to perturb the LADDER rather than a comment beside it, and this is the smallest
# edit that is unambiguously code: a reader cannot answer "the gate only notices comments".
# The perturbed copy is never compiled, only hashed, so an empty statement is legal here in
# a way it would not be in the tree.
patched = text[:brace + 1] + ';' + text[brace + 1:]
else:
patched = (text[:brace + 1] +
'\n // p3a_untouched_regions.sh --self-test: a body that MOVED.\n' +
text[brace + 1:])
open(dst, 'w', encoding='utf-8', newline='').write(patched)
return 0
@@ -282,8 +313,8 @@ def main(argv):
for sha, name in rows:
sys.stdout.write('%s %s\n' % (sha, name))
return 2 if problems else 0
if mode == 'perturb':
return perturb(argv[2], argv[3], names, argv[4])
if mode in ('perturb', 'perturb-token'):
return perturb(argv[2], argv[3], names, argv[4], mode == 'perturb-token')
sys.stderr.write('[p3a-untouched] unknown mode %r\n' % mode)
return 2
@@ -304,38 +335,74 @@ extract_ref() {
return $?
}
# The BASELINE side (<ref-a>). Same extraction, with one difference that the eleventh row makes
# necessary: a function born in P3a has no body at the P3a base ref, and CI passes exactly that
# ref as <ref-a>. Asking for it there is not "the gate could not run" - it is the expected
# answer - so a name in PINNED_FUNCTIONS that is missing at <ref-a> takes the sha pinned at the
# top of this script instead. Everything else still has to be found: the fallback is entered
# only after a strict extraction failed, and it then re-extracts the pre-P3a ten strictly, so a
# genuine rename of one of THOSE is still exit 2 rather than a silently short list.
# True when $1 is one of the rows whose baseline is the PIN rather than <ref-a>.
is_pinned_row() {
local name candidate
for candidate in $PINNED_FUNCTIONS; do
[ "$candidate" = "$1" ] && return 0
done
return 1
}
# Overwrite the pinned rows of a `<sha> <name>` list with the shas PINNED at the top of this
# script. ONE spelling of the substitution, called both by extract_baseline and by --self-test's
# pin controls, so the control drives the gate's own path instead of re-spelling it - a control
# that re-implements what it checks proves only that the copy agrees with itself.
apply_pinned_shas() {
local file=$1 name pinned
for name in $PINNED_FUNCTIONS; do
eval "pinned=\$PINNED_SHA_$name"
if [ -z "$pinned" ] || [ "$pinned" = "PLACEHOLDER_SHA" ]; then
say "$name has no pinned baseline sha; the eleventh row cannot be compared"
return 2
fi
grep -v " $name\$" "$file" > "$file.unpinned" || true
mv -f "$file.unpinned" "$file" || return 2
# Appended LAST, which is also its position in the fixed order (it is the eleventh of eleven),
# so the header's "stdout is always the sha list in the fixed order" stays true and a baseline
# captured by redirect still diffs cleanly against a two-ref run.
printf '%s %s\n' "$pinned" "$name" >> "$file"
done
return 0
}
# The BASELINE side (<ref-a>). The TEN are extracted strictly, so a rename of one of THOSE is
# exit 2 rather than a silently short list. FlushPendingRangesFrom then takes the PINNED sha
# WHETHER OR NOT <ref-a> defines it (ID-41), and a <ref-a> that defines it DIFFERENTLY is
# reported - loudly - because the two answers disagreeing is itself a finding rather than a
# reason to prefer the ref.
extract_baseline() {
local ref=$1 out=$2 blob="$WORK_DIR/$2.cpp" name sha
local ref=$1 out=$2 blob="$WORK_DIR/$2.cpp" name pinned atRef
if ! git show "$ref:$SOURCE_PATH" > "$blob" 2>"$WORK_DIR/show.err"; then
say "cannot read $SOURCE_PATH at '$ref':"
sed 's/^/[p3a-untouched] /' "$WORK_DIR/show.err" >&2
return 2
fi
if python3 "$PY" extract "$blob" "$ALL_FUNCTIONS" > "$WORK_DIR/$out.sha" 2>"$WORK_DIR/$out.err"; then
return 0
fi
if ! python3 "$PY" extract "$blob" "$FUNCTIONS" > "$WORK_DIR/$out.sha"; then
if ! python3 "$PY" extract "$blob" "$FUNCTIONS" > "$WORK_DIR/$out.sha" 2>"$WORK_DIR/$out.err"; then
say "the baseline ref '$ref' does not define the pre-P3a ten exactly once each:"
sed 's/^/[p3a-untouched] /' "$WORK_DIR/$out.err" >&2
return 2
fi
for name in $PINNED_FUNCTIONS; do
eval "sha=\$PINNED_SHA_$name"
if [ -z "$sha" ] || [ "$sha" = "PLACEHOLDER_SHA" ]; then
say "$name has no pinned baseline sha; the eleventh row cannot be compared"
return 2
eval "pinned=\$PINNED_SHA_$name"
atRef=$(python3 "$PY" extract "$blob" "$name" 2>/dev/null | awk -v n="$name" '$2 == n { print $1 }')
if [ -z "$atRef" ]; then
say "$name is not defined at '$ref' (it was born in P3a): its baseline is the sha PINNED in"
say " this script, captured at $PINNED_BASELINE_REF ($PINNED_BASELINE_DECISION)"
elif [ "$atRef" != "$pinned" ]; then
say "NOTE: $name IS defined at '$ref' and hashes"
say " $atRef, which is not the pin"
say " ($pinned,"
say " captured at $PINNED_BASELINE_REF, $PINNED_BASELINE_DECISION). THE PIN IS WHAT IS"
say " COMPARED - it is the reviewed body - and this note is not a verdict in either"
say " direction. If '$ref' PREDATES $PINNED_BASELINE_REF the two SHOULD disagree: the pinned"
say " body is the change $PINNED_BASELINE_DECISION admitted, which is why it was re-pinned"
say " rather than reverted. If it does not predate it, the ladder that ships has moved away"
say " from the reviewed text without this gate being re-pinned - re-pin deliberately or"
say " revert, but do not leave them disagreeing."
fi
printf '%s %s\n' "$sha" "$name" >> "$WORK_DIR/$out.sha"
say "$name is not defined at '$ref' (it was born in P3a): its baseline is the sha PINNED in"
say " this script, captured at $PINNED_BASELINE_REF"
done
apply_pinned_shas "$WORK_DIR/$out.sha" || return 2
return 0
}
@@ -350,6 +417,16 @@ compare_lists() {
say "FIRST FUNCTION THAT MOVED: $name"
say " $labelA $shaA"
say " $labelB ${shaB:-<not found>}"
if is_pinned_row "$name"; then
# ITS OWN MESSAGE, and that is R-16 rather than decoration: the pinned row and the ten
# ref-a rows fail differently and are fixed differently, so a reader who sees only the
# generic paragraph below goes looking for a diff against <ref-a> that does not exist.
say " $name IS A PINNED ROW ($PINNED_BASELINE_DECISION): its baseline is ALWAYS the sha"
say " PINNED in this script - the body reviewed at $PINNED_BASELINE_REF - and never the"
say " body at '$labelA'. So this is not a diff against the base ref: the ladder that"
say " SHIPS has moved away from the text that was reviewed. Either re-pin deliberately,"
say " replacing the sha AND the commit AND the decision beside it, or revert the body."
fi
say " G5 (ARCHITECTURE.md:316, :515) says the buffer pool, the deferred-release drain, the"
say " three rings and BOTH arms of the three-tier flush drain - FlushPendingRangesNow in"
say " the pull build, FlushPendingRangesFrom in the push build (BRIEF-P3A.md:420, :1708,"
@@ -436,6 +513,81 @@ if [ "${1:-}" = "--self-test" ]; then
fi
say "negative control: a perturbed $target body is reported, and named"
done
# --- THE PINNED ROW (ID-41) -----------------------------------------------------------------
# TWO more controls, and they exist because none of the five above can see the pin at all: every
# one of them compares one extraction of the working tree against another, so they would all be
# green on a build of this script in which PINNED_SHA_* was never read by anything. The eleventh
# row's whole claim is "the baseline is the PIN, not <ref-a>", and that claim needs its own two.
for target in $PINNED_FUNCTIONS; do
eval "pinned=\$PINNED_SHA_$target"
# (1) PIN PRECEDENCE, positive. A baseline that carries some OTHER sha for the pinned row -
# which is the shape of every <ref-a> CI passes today, since ff2994d9 and 37da3c3a both DEFINE
# FlushPendingRangesFrom - must come out of apply_pinned_shas carrying the PIN. This is the
# control that would have caught the ID-41 defect itself: before it, the pin was consulted
# only when <ref-a> lacked the function, so the row silently reverted to ref-a-vs-ref-b the
# moment a base ref had one.
grep -v " $target\$" "$WORK_DIR/pristine.sha" > "$WORK_DIR/pinprec.sha" || true
printf '%s %s\n' \
"0000000000000000000000000000000000000000000000000000000000000000" "$target" \
>> "$WORK_DIR/pinprec.sha"
apply_pinned_shas "$WORK_DIR/pinprec.sha" || exit 2
got=$(awk -v n="$target" '$2 == n { print $1 }' "$WORK_DIR/pinprec.sha")
if [ "$got" != "$pinned" ]; then
say "PIN CONTROL FAILED: a baseline that carried a DIFFERENT sha for $target came out as"
say " '${got:-<absent>}' and not as the pin ($pinned). The eleventh row would be compared"
say " against <ref-a> again, which is exactly the defect $PINNED_BASELINE_DECISION closed."
exit 2
fi
say "pin control: a baseline that defines $target differently is overridden by the PIN"
# (2) A ONE-TOKEN EDIT TO THE PINNED LADDER, negative, AGAINST THE PIN. The comparison must go
# red, must name the row, and must say that the row is PINNED - R-16's "a control asserts its
# OWN failure string": the pinned row and the ten ref-a rows are fixed differently, and a
# reader who gets only the generic paragraph goes looking for a diff against <ref-a> that does
# not exist.
python3 "$PY" perturb-token "$WORK_DIR/pristine.cpp" "$WORK_DIR/pinperturbed.cpp" \
"$target" "$ALL_FUNCTIONS" || exit 2
python3 "$PY" extract "$WORK_DIR/pinperturbed.cpp" "$ALL_FUNCTIONS" \
> "$WORK_DIR/pinperturbed.sha" || exit 2
cp -f "$WORK_DIR/pristine.sha" "$WORK_DIR/pinbase.sha" || exit 2
apply_pinned_shas "$WORK_DIR/pinbase.sha" || exit 2
if compare_lists "$WORK_DIR/pinbase.sha" "$WORK_DIR/pinperturbed.sha" \
"PIN($PINNED_BASELINE_REF)" "one-token-perturbed" 2> "$WORK_DIR/pinperturbed.err"; then
say "NEGATIVE CONTROL DID NOT TRIP: one token was inserted into $target's body and the"
say "comparison AGAINST THE PIN still reported every function as identical. The pinned row is"
say "not being compared at all, so every green this gate has printed for it means nothing."
exit 2
fi
if ! grep -q "FIRST FUNCTION THAT MOVED: $target" "$WORK_DIR/pinperturbed.err"; then
say "NEGATIVE CONTROL TRIPPED FOR THE WRONG REASON: the comparison against the pin went red"
say "but did not name $target as the first function that moved. It said:"
sed 's/^/[p3a-untouched] /' "$WORK_DIR/pinperturbed.err" >&2
exit 2
fi
if ! grep -q "$target IS A PINNED ROW" "$WORK_DIR/pinperturbed.err"; then
say "NEGATIVE CONTROL TRIPPED FOR THE WRONG REASON: it went red and named $target, but did"
say "not say that this row's baseline is the PIN. That is the half a reader acts on, and a"
say "control that does not assert its own message is not a control (R-16). It said:"
sed 's/^/[p3a-untouched] /' "$WORK_DIR/pinperturbed.err" >&2
exit 2
fi
say "negative control: a ONE-TOKEN edit to the pinned $target body goes red AGAINST THE PIN,"
say " is named, and says the row is pinned"
# NOT a failure, deliberately: --self-test is about whether the comparison works, and it runs
# on the WORKING tree, which may legitimately carry an uncommitted edit. The two-ref gate is
# what fails when the committed ladder has left the pin. But say so, because a self-test that
# was green on a tree whose ladder no longer matches its pin is confusing in exactly one
# direction.
treeSha=$(awk -v n="$target" '$2 == n { print $1 }' "$WORK_DIR/pristine.sha")
if [ "$treeSha" != "$pinned" ]; then
say "NOTE: this working tree's $target hashes $treeSha, not the pin ($pinned). The"
say " self-test's verdict is unaffected; the two-ref gate will be RED until you re-pin or"
say " revert."
fi
done
say "self-test passed"
exit 0
fi
+181 -34
View File
@@ -110,12 +110,21 @@
# next reader of this file meets them:
# D-N/1 the namespace region kind, above.
# D-N/2 the PINNED baseline is CONSULTED UNCONDITIONALLY for FlushPendingRangesFrom, where the
# parent consults it only when <ref-a> does not define the function. D-N says that row is
# compared "against its pinned 3e298c9a sha", and at P4a's base ref the function DOES
# exist - so the parent's fallback would silently never fire and the pin would stop being
# the baseline the brief names. Both readings agree on this tree (measured: the body at
# 37da3c3a hashes to the pinned value); where they would ever disagree, this script says
# so on stderr and keeps the PIN, because the pin is the reviewed text.
# parent consulted it only when <ref-a> did not define the function. D-N says that row is
# compared "against its pinned sha", and at P4a's base ref the function DOES exist - so
# the parent's fallback would silently never fire and the pin would stop being the
# baseline the brief names. INTEGRATOR DECISION ID-41 has since made this the parent's
# reading too, so D-N/2 is no longer a deviation between the two scripts; it is kept here
# as the record of why this one got there first.
#
# The two answers now DISAGREE on every ref CI passes, and that is the expected state
# rather than a finding: P5 (b1) re-parameterised the shipping ladder
# (hostBaseFrom/hostBaseTo, a MOBILEGL_PIPE_VERIFY-only StageSnapshotTooNarrow log, the
# tier-1 access computation moved into InvalidateFlushAccessFor), the PULL arm is
# byte-identical across that change and G1 reports .text +0, and ID-41 ruled that the row
# be RE-PINNED on the reviewed P5 body rather than reverted or quietly left comparing
# against <ref-a>. This script says so on stderr, in both directions, and keeps the PIN -
# because the pin is the reviewed text.
set -u -o pipefail
# One row per region: <name>@<kind>@<path>. The ORDER is the fixed order the sha list is printed
@@ -143,11 +152,20 @@ ShouldUseCaveatTextureFormat@function@MobileGL/MG_Backend/DirectGLES/Utils.cpp"
EXPECTED_FUNCTION_COUNT=17
# The one region born in P3a, so there is no body at P4a's base ref that this phase reviewed: its
# baseline is the sha captured at 3e298c9a, the commit at which the two-arm shape was reviewed and
# accepted (ID-15). See DEVIATIONS D-N/2 for why it is consulted unconditionally.
# baseline is PINNED and consulted UNCONDITIONALLY (DEVIATIONS D-N/2, and ID-41 for the parent).
#
# THE PIN, AND WHAT RE-PINS IT. Whoever moves this body deliberately replaces BOTH lines and
# writes the decision beside them; a pin with no commit and no decision next to it is a number
# nobody can audit. The parent script carries the identical table and the two must not drift.
# 3e298c9a 37fc94ff... ID-15, P3a: the two-arm shape, reviewed and accepted
# 3dadd4c1 172b0222... ID-41, P5 (b1): [Fix] (DirectGLES): make the extent hostBase is good
# for a parameter of the flush ladder, so tier 1's widening refusal is
# live code the moment a SEG_STAGE snapshot is narrower than the queued
# range. <- CURRENT
PINNED_FUNCTIONS="FlushPendingRangesFrom"
PINNED_BASELINE_REF=3e298c9a
PINNED_SHA_FlushPendingRangesFrom=37fc94ffc5991923d222d585daa3af6511d2352d255623026ce35a3b6963c4a6
PINNED_BASELINE_REF=3dadd4c1
PINNED_BASELINE_DECISION=ID-41
PINNED_SHA_FlushPendingRangesFrom=172b022273db01b16e772d15b269ffcd797fe38c767f7354d83ce113a66040d0
# The regions the self-test perturbs, one negative control each. FOUR, exactly as D-N requires, and
# each is a different shape so that a control which only ever perturbed the easy one cannot leave
@@ -372,7 +390,7 @@ def extract(rows):
def perturb(rows, target, src, dst, where='head'):
"""Insert one line into a region's body, at its HEAD or at its TAIL.
"""Insert one line into a region's body at its HEAD or its TAIL, or one TOKEN at its head.
TWO POSITIONS, AND THE SECOND ONE IS REVIEW FINDING F-m2. Every control used to insert at the
very first byte after the opening brace, so all four of them would still have tripped if
@@ -393,7 +411,15 @@ def perturb(rows, target, src, dst, where='head'):
% (target, len(hits)))
return 2
begin, end = hits[0]
if where == 'tail':
if where == 'token':
# ONE TOKEN - a single empty statement at the head of the body - and nothing else.
# ID-41(d) asks the pinned row's control to perturb the LADDER rather than a comment
# beside it, and this is the smallest edit that is unambiguously code: a reader cannot
# answer "the gate only notices comments". The perturbed copy is never compiled, only
# hashed, so an empty statement is legal here in a way it would not be in the tree.
brace = masked.index('{', begin)
patched = text[:brace + 1] + ';' + text[brace + 1:]
elif where == 'tail':
# end is one PAST the closing brace (find_function / find_namespace both return
# `match_forward(...) + 1`), so end - 1 is the brace itself and this lands inside the
# body, one character before it ends.
@@ -495,31 +521,59 @@ extract_baseline() {
sed 's/^/[p4a-untouched] /' "$WORK_DIR/$out.err" >&2
return 2
fi
for name in $PINNED_FUNCTIONS; do
eval "pinned=\$PINNED_SHA_$name"
grep "^$name$(printf '\t')" "$WORK_DIR/$out.spec.all" > "$WORK_DIR/$out.spec.pinned" || true
atRef=$(python3 "$PY" extract "$WORK_DIR/$out.spec.pinned" 2>/dev/null | awk '{ print $1 }')
if [ -n "$atRef" ] && [ "$atRef" != "$pinned" ]; then
say "NOTE: $name IS defined at '$ref' and hashes"
say " $atRef, which is not the pin"
say " ($pinned,"
say " captured at $PINNED_BASELINE_REF, $PINNED_BASELINE_DECISION). THE PIN IS WHAT IS"
say " COMPARED - it is the reviewed body - and this note is not a verdict in either"
say " direction. If '$ref' PREDATES $PINNED_BASELINE_REF the two SHOULD disagree: the pinned"
say " body is the change $PINNED_BASELINE_DECISION admitted, which is why it was re-pinned"
say " rather than reverted. If it does not predate it, the ladder that ships has moved away"
say " from the reviewed text without this gate being re-pinned - re-pin deliberately or"
say " revert, but do not leave them disagreeing."
fi
done
# The pinned rows are appended and the list is put back into the FIXED ORDER, both inside
# apply_pinned_shas - ONE spelling of the substitution, which --self-test's pin controls drive
# as well, so a control cannot prove only that a copy of the logic agrees with itself.
apply_pinned_shas "$WORK_DIR/$out.sha" || return 2
return 0
}
# True when $1 is one of the rows whose baseline is the PIN rather than <ref-a>.
is_pinned_row() {
local name candidate
for candidate in $PINNED_FUNCTIONS; do
[ "$candidate" = "$1" ] && return 0
done
return 1
}
# Overwrite the pinned rows of a `<sha> <region>` list with the shas PINNED at the top of this
# script, then restore the FIXED ORDER (review F-m1: the pinned rows would otherwise be emitted
# LAST while extract_ref emits everything in REGIONS order. The gate itself never noticed -
# compare_lists looks rows up by name - but the documented capture workflow did: the header
# promises "stdout is always the sha list ... in the fixed order above - so a baseline capture is a
# plain redirect", and a baseline captured that way then diffed against a two-ref stdout showed
# seven spurious differences purely from row order).
apply_pinned_shas() {
local file=$1 name pinned
for name in $PINNED_FUNCTIONS; do
eval "pinned=\$PINNED_SHA_$name"
if [ -z "$pinned" ] || [ "$pinned" = "PLACEHOLDER_SHA" ]; then
say "$name has no pinned baseline sha; that row cannot be compared"
return 2
fi
grep "^$name$(printf '\t')" "$WORK_DIR/$out.spec.all" > "$WORK_DIR/$out.spec.pinned" || true
atRef=$(python3 "$PY" extract "$WORK_DIR/$out.spec.pinned" 2>/dev/null | awk '{ print $1 }')
if [ -n "$atRef" ] && [ "$atRef" != "$pinned" ]; then
say "NOTE: $name IS defined at '$ref' and hashes $atRef, which is NOT the sha pinned in this"
say " script ($pinned, captured at $PINNED_BASELINE_REF). The PIN is what is compared - it is"
say " the reviewed text (ID-15) - but the two disagreeing means the push ladder moved between"
say " $PINNED_BASELINE_REF and '$ref' without this gate being re-pinned. Re-pin deliberately or"
say " revert; do not leave them disagreeing."
fi
printf '%s %s\n' "$pinned" "$name" >> "$WORK_DIR/$out.sha"
grep -v " $name\$" "$file" > "$file.unpinned" || true
mv -f "$file.unpinned" "$file" || return 2
printf '%s %s\n' "$pinned" "$name" >> "$file"
done
# ...and put the list back into the FIXED ORDER (review F-m1). The pinned rows were stripped out
# of the spec above and appended here, so without this the baseline side emits them LAST while
# extract_ref emits everything in REGIONS order. The gate itself never noticed - compare_lists
# looks rows up by name - but the documented capture workflow did: the header promises "stdout is
# always the sha list ... in the fixed order above - so a baseline capture is a plain redirect",
# and a baseline captured that way then diffed against a two-ref stdout showed seven spurious
# differences purely from row order.
reorder_sha_list "$WORK_DIR/$out.sha" || return 2
reorder_sha_list "$file" || return 2
return 0
}
@@ -553,6 +607,18 @@ compare_lists() {
say "FIRST REGION THAT MOVED: $name"
say " $labelA ${shaA:-<not found>}"
say " $labelB ${shaB:-<not found>}"
if is_pinned_row "$name"; then
# ITS OWN MESSAGE, and that is R-16 rather than decoration: the pinned row and the
# sixteen ref-a rows fail differently and are fixed differently, so a reader who sees
# only the generic paragraph below goes looking for a diff against <ref-a> that does not
# exist.
say " $name IS A PINNED ROW ($PINNED_BASELINE_DECISION): its baseline is ALWAYS the sha"
say " PINNED in this script - the body reviewed at $PINNED_BASELINE_REF - and never the"
say " body at '$labelA'. So this is not a diff against the base ref: the ladder that"
say " SHIPS has moved away from the text that was reviewed. Either re-pin deliberately,"
say " replacing the sha AND the commit AND the decision beside it in BOTH this script"
say " and its parent scripts/p3a_untouched_regions.sh, or revert the body."
fi
say " G5 (ARCHITECTURE.md:318, :321, :515) says the Espryt do-not-touch list is literal:"
say " P3a's buffer pool, deferred-release drain, three rings and BOTH arms of the three-tier"
say " flush drain, plus P4a's unpack-PBO staging repack and its two ring helpers, the"
@@ -573,10 +639,12 @@ compare_lists() {
# --- self-test ------------------------------------------------------------------------------
# A gate that always says "identical" and a gate that is working produce the same green, so the
# comparison has to be shown failing. Both controls run: the POSITIVE ones (an untouched copy
# compares equal; an edit OUTSIDE the regions is invisible) rule out a comparison that reports
# every region as moved, and the eight NEGATIVE ones - D-N's four regions, each perturbed at the
# HEAD of its body and again at its TAIL - rule out both the comparison that never reports any and
# the extraction whose extent stops before the closing brace (F-m2).
# compares equal; an edit OUTSIDE the regions is invisible; a baseline that names another sha for
# the PINNED row is overridden by the pin) rule out a comparison that reports every region as
# moved, and the NINE NEGATIVE ones - D-N's four regions, each perturbed at the HEAD of its body
# and again at its TAIL, plus a ONE-TOKEN edit to the pinned ladder compared AGAINST THE PIN
# (ID-41) - rule out the comparison that never reports any, the extraction whose extent stops
# before the closing brace (F-m2), and a pin that nothing consults.
if [ "${1:-}" = "--self-test" ]; then
[ $# -eq 1 ] || { say "--self-test takes no other arguments"; exit 2; }
mkdir -p "$WORK_DIR/pristine" || exit 2
@@ -678,6 +746,85 @@ if [ "${1:-}" = "--self-test" ]; then
say "its tail), ran $controls"
exit 2
fi
# --- THE PINNED ROW (ID-41) -----------------------------------------------------------------
# TWO more controls, and they exist because none of the ten above can see the pin at all: every
# one of them compares one extraction of the working tree against another, so they would all be
# green on a build of this script in which PINNED_SHA_* was never read by anything. The pinned
# row's whole claim is "the baseline is the PIN, not <ref-a>" (D-N/2, ID-41), and that claim
# needs its own two.
for target in $PINNED_FUNCTIONS; do
eval "pinned=\$PINNED_SHA_$target"
targetSource=$(printf '%s\n' "$REGIONS" | awk -F@ -v n="$target" '$1 == n { print $3 }')
[ -n "$targetSource" ] || { say "$target is not one of the regions"; exit 2; }
# (1) PIN PRECEDENCE, positive. A baseline that carries some OTHER sha for the pinned row -
# which is the shape of every <ref-a> CI passes, since 37da3c3a DOES define
# FlushPendingRangesFrom and no longer hashes the pin - must come out of apply_pinned_shas
# carrying the PIN.
grep -v " $target\$" "$WORK_DIR/pristine.sha" > "$WORK_DIR/pinprec.sha" || true
printf '%s %s\n' \
"0000000000000000000000000000000000000000000000000000000000000000" "$target" \
>> "$WORK_DIR/pinprec.sha"
apply_pinned_shas "$WORK_DIR/pinprec.sha" || exit 2
got=$(awk -v n="$target" '$2 == n { print $1 }' "$WORK_DIR/pinprec.sha")
if [ "$got" != "$pinned" ]; then
say "PIN CONTROL FAILED: a baseline that carried a DIFFERENT sha for $target came out as"
say " '${got:-<absent>}' and not as the pin ($pinned). That row would be compared against"
say " <ref-a> again, which is exactly what D-N/2 and $PINNED_BASELINE_DECISION forbid."
exit 2
fi
say "pin control: a baseline that defines $target differently is overridden by the PIN"
# (2) A ONE-TOKEN EDIT TO THE PINNED LADDER, negative, AGAINST THE PIN. The comparison must go
# red, must name the region, and must say the region is PINNED - R-16's "a control asserts its
# OWN failure string": the pinned row and the sixteen ref-a rows are fixed differently, and a
# reader who gets only the generic paragraph goes looking for a diff against <ref-a> that does
# not exist.
rm -rf "$WORK_DIR/pinperturbed"
cp -r "$WORK_DIR/pristine" "$WORK_DIR/pinperturbed" || exit 2
write_spec "$WORK_DIR/pinperturbed" "$WORK_DIR/pinperturbed.spec"
python3 "$PY" perturb "$WORK_DIR/pinperturbed.spec" "$target" \
"$WORK_DIR/pristine/$(blob_name "$targetSource")" \
"$WORK_DIR/pinperturbed/$(blob_name "$targetSource")" token || exit 2
python3 "$PY" extract "$WORK_DIR/pinperturbed.spec" > "$WORK_DIR/pinperturbed.sha" || exit 2
cp -f "$WORK_DIR/pristine.sha" "$WORK_DIR/pinbase.sha" || exit 2
apply_pinned_shas "$WORK_DIR/pinbase.sha" || exit 2
if compare_lists "$WORK_DIR/pinbase.sha" "$WORK_DIR/pinperturbed.sha" \
"PIN($PINNED_BASELINE_REF)" "one-token-perturbed" 2> "$WORK_DIR/pinperturbed.err"; then
say "NEGATIVE CONTROL DID NOT TRIP: one token was inserted into $target's body and the"
say "comparison AGAINST THE PIN still reported every region as identical. The pinned row is"
say "not being compared at all, so every green this gate has printed for it means nothing."
exit 2
fi
if ! grep -q "FIRST REGION THAT MOVED: $target" "$WORK_DIR/pinperturbed.err"; then
say "NEGATIVE CONTROL TRIPPED FOR THE WRONG REASON: the comparison against the pin went red"
say "but did not name $target as the first region that moved. It said:"
sed 's/^/[p4a-untouched] /' "$WORK_DIR/pinperturbed.err" >&2
exit 2
fi
if ! grep -q "$target IS A PINNED ROW" "$WORK_DIR/pinperturbed.err"; then
say "NEGATIVE CONTROL TRIPPED FOR THE WRONG REASON: it went red and named $target, but did"
say "not say that this row's baseline is the PIN. That is the half a reader acts on, and a"
say "control that does not assert its own message is not a control (R-16). It said:"
sed 's/^/[p4a-untouched] /' "$WORK_DIR/pinperturbed.err" >&2
exit 2
fi
controls=$((controls + 1))
say "negative control $controls: a ONE-TOKEN edit to the pinned $target body goes red AGAINST"
say " THE PIN, is named, and says the row is pinned"
# NOT a failure, deliberately: --self-test is about whether the comparison works, and it runs
# on the WORKING tree, which may legitimately carry an uncommitted edit. The two-ref gate is
# what fails when the committed region has left the pin.
treeSha=$(awk -v n="$target" '$2 == n { print $1 }' "$WORK_DIR/pristine.sha")
if [ "$treeSha" != "$pinned" ]; then
say "NOTE: this working tree's $target hashes $treeSha, not the pin ($pinned). The"
say " self-test's verdict is unaffected; the two-ref gate will be RED until you re-pin or"
say " revert."
fi
done
say "self-test passed: $controls negative controls, all tripped and all named"
exit 0
fi