[MG_Backend, MG_Impl, MG_Remote, MG_Test, MG_IntegrationTest, CI] (Disaggregated): P5f f1 - the dual-block rehearsal: per-role PipeInputs, the MOBILEGL_IPC_ROLE_SPLIT_STATE knob, and the named-Fatal arm that makes the red a census

This commit is contained in:
2026-09-19 09:59:33 -04:00
parent e75e00cb7a
commit cfcc12f73a
15 changed files with 833 additions and 13 deletions
+64
View File
@@ -1341,6 +1341,70 @@ jobs:
fi
echo "integration-split under strict is GREEN, two-sided, with the arming control present"
# ---- P5f (f1), P5F-WIRE-COMPLETENESS.md §4: THE DUAL-BLOCK CENSUS LANE ---------------
#
# The same integration-split entries (plus Magma's own lane), re-run under
# MOBILEGL_IPC_ROLE_SPLIT_STATE=1: the client's residual fill writes a client-role block
# and the backend reads a server-role one, so every BARRIER-PULLED read aborts with a
# named Fatal{UnmigratedPipeInput, "<field>@<verb>"}. THE RED IS THE CENSUS - it is the
# machine-checked list of what still depends on the two roles sharing one object, and it
# is ratcheted two-sided against Harness/dualblock-expected-fatals.txt: a new pair is a
# debt somebody now owes, a vanished pair must be removed in the commit that retired it.
#
# THE STEP ALSO FAILS WHEN THE LANE IS GREEN while the expected file still has rows -
# "green" would mean the knob stopped arming, which is exactly the decay ID-122 assigns
# to a control rather than to a lane's good mood. At P5f's exit the file is empty and the
# green arm is the correct one; delete the red expectation in the same commit.
- name: Integration-split under the dual-block rehearsal - expected red, census ratcheted (P5f f1)
working-directory: build-split
env:
MOBILEGL_ITEST_REQUIRE_GPU: "1"
MOBILEGL_TRANSPORT: "inproc"
MOBILEGL_IPC_ROLE_SPLIT_STATE: "1"
MOBILEGL_IPC_STRICT_ERRORS: "1"
run: |
ulimit -c unlimited
helper="${GITHUB_WORKSPACE}/MobileGL/MG_IntegrationTest/Harness/split_log_paths.py"
expected="${GITHUB_WORKSPACE}/MobileGL/MG_IntegrationTest/Harness/dualblock-expected-fatals.txt"
ctest -L 'integration-split|integration-magma-split' --show-only=json-v1 \
> "${RUNNER_TEMP}/dualblock-lane.json"
# Stale logs from an earlier step would be read as this run's evidence (ID-119's rule).
python3 - "${RUNNER_TEMP}/dualblock-lane.json" "${helper}" <<'PY'
import importlib.util, json, os, sys
spec = importlib.util.spec_from_file_location("slp", sys.argv[2])
slp = importlib.util.module_from_spec(spec); spec.loader.exec_module(slp)
logs = slp.marker_log_paths(json.load(open(sys.argv[1])))
for path in set(logs.values()):
try:
os.unlink(path)
except OSError:
pass
print(f"reset {len(set(logs.values()))} private log(s) across {len(logs)} entries")
PY
rc=0
ctest -L 'integration-split|integration-magma-split' --no-tests=error -j 4 \
--output-junit "${RUNNER_TEMP}/dualblock.xml" \
> "${RUNNER_TEMP}/dualblock-split.txt" 2>&1 || rc=$?
tail -5 "${RUNNER_TEMP}/dualblock-split.txt"
echo "dual-block census run: rc=${rc}"
rows=$(grep -cvE '^\s*(#|$)' "${expected}")
if [ "${rc}" -eq 0 ] && [ "${rows}" -gt 0 ]; then
echo "::error::the dual-block census lane went GREEN with ${rows} expected pair(s) outstanding - the knob stopped arming, or the fields retired and dualblock-expected-fatals.txt was not emptied in the same commit"
exit 1
fi
python3 "${helper}" expect-fatal "${RUNNER_TEMP}/dualblock-lane.json" \
"${RUNNER_TEMP}/dualblock.xml" "${expected}"
# The mechanism's own negative control (P5F §6): the same named case must be green with
# the knob on and red - on its own assertion text - with it off.
- name: Dual-block negative control (P5f f1, P5F section 6)
working-directory: build-split
run: bash "${GITHUB_WORKSPACE}/scripts/ci/dualblock_negative_control.sh"
# ---- P5e (gl), CONTRACT-P5E §7: MAGMA'S OWN LANE, EXPECTED RED -----------------------
#
# The two DirectVulkan.Split.NamedBlit entries abort on GetFramebufferBindingSlot@Clear
+11
View File
@@ -535,6 +535,17 @@ namespace MobileGL::MG_Config {
// build, the seven sticky forwards that are otherwise exempt - from "count it in
// rsp" to Fatal (R-7.3).
Bool StrictErrors = false;
// MOBILEGL_IPC_ROLE_SPLIT_STATE (P5f f1, P5F-WIRE-COMPLETENESS.md §4): the dual-block
// rehearsal. 1 = the client's residual fill writes a CLIENT-ROLE PipeInputs block and
// the backend/applier keep reading the SERVER-ROLE one, so every path that today works
// only because the two roles share one object turns into a named
// Fatal{UnmigratedPipeInput, "<field>@<verb>"} instead of a silent cross-role read.
// Meaningless under monolith transport (the two roles are one thread there, so the
// selection folds to the single shared block) and forced off by MOBILEGL_PIPE_VERIFY
// (the comparator owns the one fill block it compares against). 0 is not merely the
// default, it is the negative control: the lane's distinctness case must go red
// without it (P5F §6).
Bool RoleSplitState = false;
// MOBILEGL_IPC_AUDIT: after a record retires, the server fills the SEG_STAGE bytes
// it referenced with 0xDD (R-2.5). This is the ONLY mechanical control that an
// inproc implementation did not quietly keep using a pointer past its lifetime.
+13 -2
View File
@@ -393,17 +393,28 @@ namespace MobileGL::MG_ConfigLoader {
ipc.StrictErrors = QueryEnvFlag("MOBILEGL_IPC_STRICT_ERRORS");
ipc.Audit = QueryEnvFlag("MOBILEGL_IPC_AUDIT");
QueryEnvVariable("MOBILEGL_IPC_SERVER_AFFINITY", ipc.ServerAffinity, "auto");
// P5f f1: the dual-block rehearsal (P5F-WIRE-COMPLETENESS.md §4). Forced OFF by the
// verify harness: the comparator's entry compare and compare-at-read hook are built on
// there being ONE filled block (the hook pins itself to &gPipeInputs,
// PipeFill.cpp's MGPipeVerifyReadHook), which is exactly what the rehearsal splits.
ipc.RoleSplitState = QueryEnvFlag("MOBILEGL_IPC_ROLE_SPLIT_STATE");
if (MG_Config::Features.PipeVerify && ipc.RoleSplitState) {
MGLOG_W("Config: MOBILEGL_IPC_ROLE_SPLIT_STATE=1 is incompatible with "
"MOBILEGL_PIPE_VERIFY (the comparator owns the single fill block); "
"the dual-block rehearsal is OFF for this run");
ipc.RoleSplitState = false;
}
if (MG_Config::Transport == MG_Config::TransportMode::Monolith) return;
// One line, on the arm where these numbers decide behaviour, because every one of
// them is a number a bug report has to quote.
MGLOG_I("Config: IPC ring=%uMiB stage=%uMiB spin=%uus persistent-block=%uKiB "
"adopt-tier=%u verb-barrier=%u run-ahead=%u present-credit=%u strict=%d "
"audit=%d affinity='%s'",
"audit=%d role-split-state=%d affinity='%s'",
ipc.RingMb, ipc.StageMb, ipc.SpinUs, ipc.PersistentBlockKb, ipc.AdoptTier,
ipc.VerbBarrier, ipc.RunAhead, ipc.PresentCredit,
static_cast<int>(ipc.StrictErrors), static_cast<int>(ipc.Audit),
ipc.ServerAffinity.c_str());
static_cast<int>(ipc.RoleSplitState), ipc.ServerAffinity.c_str());
if (ipc.VerbBarrier == 0) {
MGLOG_W("Config: MOBILEGL_IPC_VERB_BARRIER=0 is the R-1 NEGATIVE CONTROL and is "
"expected to fail: the client still pulls 31 of 63 PipeInputs fields from a "
+61
View File
@@ -160,6 +160,20 @@ namespace MobileGL::MG_Pipe {
// MGPipeBarrierPullAdmitted is generated from those three tables (ID-116); there is no
// list here to drift.
void CountBarrierPull(MGPipeInputField field, MGPipeVerb verb) {
// P5f (f1), P5F-WIRE-COMPLETENESS.md §4: UNDER THE DUAL-BLOCK REHEARSAL EVERY
// BARRIER-PULLED READ IS A NAMED FATAL, unconditionally - ahead of the barriered
// question, ahead of the strict knob, ahead of the counter. The server block has no
// residual fill to answer from, so the value the read would return is not "torn"
// or "stale" but ABSENT: the field's stamp was withdrawn by the server's own verb
// boundary and nothing on this side of the role split ever writes it. Letting the
// read proceed would hand the backend default storage - or, for the O-class rows
// with no null check, an empty SharedPtr to dereference - which is an UNNAMED
// crash exactly where the rehearsal exists to produce a named one. The red is the
// census (Harness/dualblock-expected-fatals.txt), so the marker keeps the strict
// grammar with the knob's own name as the reason.
if (MGPipeRoleSplitActive()) {
StrictBarrierPullFatal(field, verb, "MOBILEGL_IPC_ROLE_SPLIT_STATE=1");
}
if (!MGPipeApplierCurrentRecordIsBarriered()) {
StrictBarrierPullFatal(field, verb, "UNBARRIERED, the client did not fill it");
}
@@ -260,8 +274,50 @@ namespace MobileGL::MG_Pipe {
static void SetServerStamped(PipeInputs& inputs, Bool stamped) {
inputs.m_serverStampedVerb = stamped;
}
// P5f (f1): the server block's identity half of the stamp door (CONTRACT-P5E §3.2).
// The client's SetIdentity lives in MG_Impl's MGPipeFillAccess and writes the CLIENT
// block now; this one is how the server block learns which served context it describes
// without MG_Impl being in the process at all.
static void SetIdentity(PipeInputs& inputs, Bool live, const void* identity) {
inputs.m_live = live;
inputs.m_contextIdentity = identity;
}
};
// ---- P5f (f1): the dual-block rehearsal's selection functions (PipeInputs.h) -----------
//
// MGPipeRoleSplitActive is deliberately NOT latched: it is two global loads, asked once per
// verb on the fill side and once per verb boundary on the stamp side, and a latch is a
// second thing a test that flips the knob mid-process would have to know about.
Bool MGPipeRoleSplitActive() {
return MG_Config::Ipc.RoleSplitState &&
MG_Config::Transport != MG_Config::TransportMode::Monolith;
}
PipeInputs& MGPipeClientInputs() {
return MGPipeRoleSplitActive() ? gPipeInputsClientBlock : gPipeInputs;
}
void MGPipeClientClearVerbBoundary() {
MGPipeStampAccess::SetServerStamped(MGPipeClientInputs(), false);
}
void MGPipeServerBlockNoteIdentity() {
if (!MGPipeRoleSplitActive()) return;
// The server cannot name the client's GLContext - under a real transport it is in
// another process - so the identity the server block carries is the server's OWN
// served-context clock: MGPipeApplierContextSerial moves exactly when the served
// context does (MGPipeApplierReset), which is all the backends' per-context memo keys
// ask of an identity. The odd-encoding keeps the token non-null at serial 0: a null
// identity reads as a hit against DirectGLES' zero-initialised fb-slot memo cache and
// hands out a null slot - an unnamed crash where the rehearsal exists to produce a
// named one.
const Uint64 serial = MGPipeApplierContextSerial();
MGPipeStampAccess::SetIdentity(gPipeInputs, true,
reinterpret_cast<const void*>(static_cast<std::uintptr_t>(
(serial << 1) | Uint64{1})));
}
void MGPipeServerStampVerbBoundary(MGPipeVerb verb) {
PipeInputs& inputs = gPipeInputs;
// P5e (gl), ID-115: THE POSITIVE CONTROL'S ONLY HONEST SIGNAL, and it belongs HERE
@@ -302,6 +358,11 @@ namespace MobileGL::MG_Pipe {
filled.FilledGen[i] = serial & (Uint64{0} - bit);
}
MGPipeStampAccess::SetServerStamped(inputs, true);
// P5f (f1): the server block's identity rides the stamp (CONTRACT-P5E §3.2's
// "SetIdentity moves to ApplyOne" - the stamp is the per-verb half of ApplyOne, and
// refreshing here rather than once at Attach keeps the token in step with
// MGPipeApplierContextSerial across a context switch). A no-op with the rehearsal off.
MGPipeServerBlockNoteIdentity();
}
void MGPipeServerClearVerbBoundary() { MGPipeStampAccess::SetServerStamped(gPipeInputs, false); }
+55
View File
@@ -834,6 +834,61 @@ namespace MobileGL::MG_Pipe {
// (P3a; the exit-time heap corruption this closes is p3a-results/exit-order-v1.md.)
inline PipeInputs& gPipeInputs = *new PipeInputs();
#if MOBILEGL_BUILD_DISAGGREGATED
// ============================================================================
// P5f (f1), P5F-WIRE-COMPLETENESS.md §4: THE DUAL-BLOCK REHEARSAL
// ============================================================================
//
// One gPipeInputs served both roles because the verb barrier (R-1) made "the client filled
// it" and "the server is reading it" mutually exclusive in TIME on the same object. The
// rehearsal removes the same-object half of that sentence: under
// MOBILEGL_IPC_ROLE_SPLIT_STATE=1 the client's residual fill writes the CLIENT block below
// and the backend/applier keep reading gPipeInputs, which is the SERVER block. Every path
// that worked only because the two were one object - a BARRIER-PULLED field, a sticky
// forward's read of the fill side's stamps - then has no value to read, and the read
// becomes a NAMED Fatal{UnmigratedPipeInput, "<field>@<verb>"} (CountBarrierPull's
// dual-block arm, PipeInputs.cpp) instead of a silent cross-role answer.
//
// THE READ SIDE'S SPELLING DOES NOT MOVE. gPipeInputs remains the server-role block, so the
// 379 MGB_CTX sites and PipeApply.cpp's applier writes are untouched; the only new spelling
// is on the fill side (MG_Impl/Pipe/PipeFill.cpp), which asks MGPipeClientInputs(). Under
// monolith transport - every unit and integration-gpu lane of a split build - the selection
// folds back to the single shared block and behaviour is byte-for-byte the old one.
//
// Same leak-at-exit storage as gPipeInputs above, for the same reason.
inline PipeInputs& gPipeInputsClientBlock = *new PipeInputs();
// PipeInputs.cpp. Whether the rehearsal is armed: the knob AND a real transport (and not
// the verify build, which ConfigLoader forces off). Constant for the life of the process.
Bool MGPipeRoleSplitActive();
// PipeInputs.cpp. THE FILL SIDE'S ONE NEW SPELLING: the client block when the rehearsal is
// armed, gPipeInputs otherwise. Everything in MG_Impl/Pipe/PipeFill.cpp that used to spell
// gPipeInputs spells this instead.
PipeInputs& MGPipeClientInputs();
// PipeInputs.cpp. The client-role half of MGPipeServerClearVerbBoundary: clears the stamp
// flag on the FILL side's block. With the rehearsal off that IS gPipeInputs, so the two
// client call sites keep their old semantics exactly; with it on the client block's flag is
// never raised (nothing server-stamps it) and the clear is a no-op - which is the point:
// the client no longer reaches into the server's block at all.
void MGPipeClientClearVerbBoundary();
// PipeInputs.cpp. CONTRACT-P5E §3.2's other half: the server block's identity is
// SERVER-OWNED. Called from PipeApplier::Attach and from MGPipeServerStampVerbBoundary;
// with the rehearsal armed it sets m_live and points m_contextIdentity at a token derived
// from MGPipeApplierContextSerial() - the server's own served-context clock, which is what
// moves when the served context does. Without it the server block's ContextIdentity() would
// stay nullptr, and DirectGLES' fb-slot memo cache compares identity FIRST (a nullptr
// against its own nullptr initialiser reads as a hit and hands out a null slot): an
// unnamed crash where the rehearsal exists to produce a named one. A no-op with the
// rehearsal off, so the client's per-verb SetIdentity keeps owning the shared block there.
void MGPipeServerBlockNoteIdentity();
#else
// The push-without-transport build has one role and one block, so the fill side's spelling
// folds onto gPipeInputs and PipeFill.cpp reads identically in both build flavours. An
// inline that no caller in such a build ever has a reason to call twice - the disaggregated
// arm above is the real one.
inline PipeInputs& MGPipeClientInputs() { return gPipeInputs; }
#endif
// Every field has storage or is forwarded, and nothing else.
#define MGP_INPUT_COUNT_ONE(Field, Member) +1
static_assert(0 MGP_INPUT_STORAGE_LIST(MGP_INPUT_COUNT_ONE) + kMGPipeForwardedFieldCount == kMGPipeInputFieldCount,
+21 -11
View File
@@ -624,9 +624,9 @@ namespace MobileGL::MG_Pipe {
}
void ReportDivergence(MGPipeInputField field, const char* where) {
const Uint64 serial = MGPipeFillAccess::Filled(gPipeInputs).CurrentVerbSerial;
const Uint64 serial = MGPipeFillAccess::Filled(MGPipeClientInputs()).CurrentVerbSerial;
MGLOG_F("MGPipe: Fatal{PipeVerifyDiffer, \"%s@%s\", verb=%llu, where=%s}",
kMGPipeInputFieldNames[static_cast<SizeT>(field)], MGPipeVerbName(gPipeInputs.CurrentVerb()),
kMGPipeInputFieldNames[static_cast<SizeT>(field)], MGPipeVerbName(MGPipeClientInputs().CurrentVerb()),
static_cast<unsigned long long>(serial), where);
if (g_verify.Fatal) std::abort();
g_verify.Divergences.fetch_add(1, std::memory_order_relaxed);
@@ -650,7 +650,7 @@ namespace MobileGL::MG_Pipe {
void SnapshotFromGLContext(PipeInputs& snapshot, const MGPipeFieldMask& mask) {
auto* ctx = LiveContext();
MGPipeFillAccess::SetIdentity(snapshot, ctx);
MGPipeFillAccess::SetVerb(snapshot, gPipeInputs.CurrentVerb());
MGPipeFillAccess::SetVerb(snapshot, MGPipeClientInputs().CurrentVerb());
if (ctx == nullptr) return;
for (SizeT i = 0; i < kMGPipeInputFieldCount; ++i) {
const auto field = static_cast<MGPipeInputField>(i);
@@ -700,7 +700,7 @@ namespace MobileGL::MG_Pipe {
// filled must stay Fatal{UnmigratedPipeInput} on the next read rather than be healed by
// an unrelated frontend write.
void MGPipeNoteFrontendMutation(MGPipeInputField field) {
PipeInputs& inputs = gPipeInputs;
PipeInputs& inputs = MGPipeClientInputs();
#if MOBILEGL_BUILD_DISAGGREGATED
// P5e (ra, CONTRACT-P5E §3.1): THIS REFRESHES ONE FIELD OF THE LAST FILL, so with no
// fill behind it there is nothing to refresh. Under run-ahead the last verb may have
@@ -1214,7 +1214,7 @@ namespace MobileGL::MG_Pipe {
// fill only for UNBARRIERED records); an unbarriered apply may not read it at all, and
// §3.3's detector is what says so by name. Clearing the stamps as well would trade that
// named abort for the poison Fatal, which names the field but not the rule it broke.
void MGPipeReleaseResidualFillPins() { MGPipeFillAccess::ReleaseObjectPins(gPipeInputs); }
void MGPipeReleaseResidualFillPins() { MGPipeFillAccess::ReleaseObjectPins(MGPipeClientInputs()); }
// P5e (ra), CONTRACT-P5E §2.5. Declared in PipeMutation.h; see there for why it is not a
// ClientSession call at the GL entry point.
@@ -2202,7 +2202,7 @@ namespace MobileGL::MG_Pipe {
Uint32 MGPipePendingBaseInstance() { return MGPipeTrackerInstance().PendingBaseInstance(); }
void MGPipeLeaveVerb() {
PipeInputs& inputs = gPipeInputs;
PipeInputs& inputs = MGPipeClientInputs();
#if MOBILEGL_BUILD_DISAGGREGATED
// P5e (ra, §3.1): a verb whose fill was skipped has no stamps of this thread's to
// retire, and the bump below would move a serial the SERVER's stamp owns. Leave
@@ -2225,7 +2225,7 @@ namespace MobileGL::MG_Pipe {
++MGPipeFillAccess::Filled(inputs).CurrentVerbSerial;
#endif
#if MOBILEGL_BUILD_DISAGGREGATED
MGPipeServerClearVerbBoundary();
MGPipeClientClearVerbBoundary();
#endif
MGPipeFillAccess::SetVerb(inputs, MGPipeVerb::kVerbCount);
// The pending base instance belongs to the verb that was about to run, so leaving
@@ -3013,7 +3013,7 @@ namespace MobileGL::MG_Pipe {
#if MOBILEGL_BUILD_DISAGGREGATED
if (ClientRunsAhead()) return sizeof(MGPVertexAttribDefaults) + header.Count * sizeof(MGPAttribValue);
#endif
const auto* mirror = MGPipeFillAccess::VertexAttribDefaultsOf(gPipeInputs);
const auto* mirror = MGPipeFillAccess::VertexAttribDefaultsOf(MGPipeClientInputs());
Bool reproduced = true;
for (SizeT i = 0; i < kAttribs && reproduced; ++i) {
if ((header.Mask & (Uint32{1} << static_cast<Uint32>(i))) == 0) continue;
@@ -3024,7 +3024,7 @@ namespace MobileGL::MG_Pipe {
MGLOG_W_ONCE("MGPipe: MGPipeApplySetVertexAttribDefaults did not reproduce the "
"carried three views on this build - the client is keeping "
"m_currentVertexAttribute authoritative");
MGPipeFillAccess::CopyField(gPipeInputs, ctx, MGPipeInputField::GetCurrentVertexAttribute);
MGPipeFillAccess::CopyField(MGPipeClientInputs(), ctx, MGPipeInputField::GetCurrentVertexAttribute);
}
return sizeof(MGPVertexAttribDefaults) + header.Count * sizeof(MGPAttribValue);
}
@@ -3345,7 +3345,10 @@ namespace MobileGL::MG_Pipe {
// ---- the validate point (P2 brief D1) ----
void MGPipeValidateForVerb(MGPipeVerb verb) {
PipeInputs& inputs = gPipeInputs;
// P5f (f1): the fill side's one new spelling. With MOBILEGL_IPC_ROLE_SPLIT_STATE=1 this
// is the CLIENT-role block and gPipeInputs is the server's alone; off, or under
// monolith transport, it folds back onto gPipeInputs and nothing below changes.
PipeInputs& inputs = MGPipeClientInputs();
#if MOBILEGL_BUILD_DISAGGREGATED
// ---- P5e (ra), CONTRACT-P5E §3: WHO OWNS gPipeInputs FOR THIS VERB ----------------
//
@@ -3434,7 +3437,14 @@ namespace MobileGL::MG_Pipe {
// while the apply thread is inside a record that depends on it. For an unbarriered
// verb the stamp is not touched at all, and the applier's own LeaveApplier is then
// its only writer.
MGPipeServerClearVerbBoundary();
//
// P5f (f1): this is now the CLIENT block's flag (MGPipeClientClearVerbBoundary).
// With the rehearsal off both names denote the one shared block and the semantics
// above are unchanged; with it on the client block's flag is never raised and the
// clear is a no-op, and withdrawing the SERVER's stamp is the applier's job alone
// (PipeApplier::LeaveApplier) - which is CONTRACT-P5E §3.2's per-role stamp
// ownership, landed as the dual block rather than as moved fields.
MGPipeClientClearVerbBoundary();
#endif
MGPipeFillAccess::SetVerb(inputs, verb);
}
@@ -2496,3 +2496,58 @@ if (MOBILEGL_BUILD_DISAGGREGATED)
ENVIRONMENT "${MGL_ITEST_GLES_SPLIT_STRICT_ARMING_ENVIRONMENT}"
)
endif()
# =====================================================================================
# P5f (f1), P5F-WIRE-COMPLETENESS.md §4/§6: THE DUAL-BLOCK REHEARSAL'S OWN LANE
# =====================================================================================
#
# The lane has TWO halves, and only one of them registers entries here:
#
# - the CENSUS half is an environment-variable lane, exactly like strict: the gate runs the
# EXISTING `integration-split` entries under MOBILEGL_IPC_ROLE_SPLIT_STATE=1 +
# MOBILEGL_IPC_STRICT_ERRORS=1, and every BARRIER-PULLED read there aborts with a named
# Fatal{UnmigratedPipeInput, "<field>@<verb>"} - the red IS the census, collected and
# ratcheted against Harness/dualblock-expected-fatals.txt by split_log_paths.py's
# `expect-fatal` mode. Re-registering the whole set under a second prefix would double the
# discovered test count for zero new information.
#
# - the CONTROL half is the one entry below: DualBlockScenario is the mechanism's negative
# control (§6: turning the knob OFF must turn a named case from green to red), so it needs
# an entry of its own, under a label of its own.
#
# THE LABEL IS `integration-dualblock-split`, and the spelling is load-bearing twice over
# (ID-131): `ctest -L` is a REGEX, so a label CONTAINING `integration-split` as a substring
# would be selected by the main lane's runs - and this one is expected-red there, which must
# never poison the hard-green gate. Checked both directions: `-L integration-split` does not
# match it (`dualblock` stands between the two words), and `-L integration-dualblock-split`
# matches nothing else in the tree. It does not carry `integration-gpu` either: that label must
# be able to go green, and this entry goes green only under the armed knob.
#
# THE KNOB IS NOT PINNED IN THE ENTRY'S ENVIRONMENT, deliberately, and that is the ONE deviation
# from the N-6 pinning rule every other split entry follows: a ctest ENVIRONMENT property
# overrides the process environment, so pinning MOBILEGL_IPC_ROLE_SPLIT_STATE=1 here would make
# the knob=0 form - which IS the negative control - unreachable through ctest. The failure
# direction is safe: a bare `-L integration-dualblock-split` run without the knob is RED by
# name, never silently green.
#
# MOBILEGL_IPC_RUN_AHEAD=0 IS pinned: the serial-separation half asserts on the fill side's
# behaviour per verb boundary, and under run-ahead an unbarriered verb fills nothing, which
# would make the assertion timing-dependent instead of deterministic.
if (MOBILEGL_BUILD_DISAGGREGATED)
target_sources(MobileGLIntegrationTest PRIVATE
Scenarios/DualBlockScenario.cpp Harness/DualBlockPeek.cpp)
mgl_itest_join_environment(MGL_ITEST_GLES_DUALBLOCK_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_TRANSPORT=inproc"
"MOBILEGL_IPC_SERVER_PATH=${MGL_ITEST_SPLIT_SERVER_PATH}" "MGITEST_SPLIT_LANE=1"
"MOBILEGL_IPC_RUN_AHEAD=0"
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES.Split.DualBlock."
TEST_FILTER "DualBlockScenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS "integration-dualblock-split"
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_DUALBLOCK_ENVIRONMENT}\;MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/split-logs/DirectGLES.Split.DualBlock.TheTwoRolesHaveDistinctBlocks.log"
)
endif()
@@ -0,0 +1,60 @@
// MobileGL - MobileGL/MG_IntegrationTest/Harness/DualBlockPeek.cpp
// Copyright (c) 2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#include "DualBlockPeek.h"
#if defined(MGITEST_SPLIT_RUNTIME_PEEK) && !defined(__ANDROID__)
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include <MG_Backend/MGPipe/PipeInputs.h>
#include <MG_Impl/Pipe/PipeFill.h>
// The serials live behind FilledState(), which exists only where the poison build flag is on -
// and PipeInputs.h derives that flag, so the question can only be asked AFTER the include. A
// disaggregated build always has it (MOBILEGL_BUILD_DISAGGREGATED is one of its three arms).
#if MOBILEGL_PIPE_POISON
#define MGITEST_DUAL_BLOCK_PEEK_LIVE 1
#endif
#endif
#endif
namespace MGITest {
#if defined(MGITEST_DUAL_BLOCK_PEEK_LIVE)
// The serials live behind the poison build's FilledState(); the peek is live exactly in
// the configurations where MOBILEGL_PIPE_POISON is (a disaggregated build always has it,
// which is what the #if above says), so an answer here is never a different build's guess.
bool PeekDualBlockState(DualBlockPeekState* out) {
if (out == nullptr) return false;
namespace MGP = MobileGL::MG_Pipe;
out->peekAvailable = true;
out->roleSplitActive = MGP::MGPipeRoleSplitActive();
MGP::PipeInputs& client = MGP::MGPipeClientInputs();
out->blocksDistinct = &client != &MGP::gPipeInputs;
out->clientSerial = client.FilledState().CurrentVerbSerial;
out->serverSerial = MGP::gPipeInputs.FilledState().CurrentVerbSerial;
out->serverIdentity = MGP::gPipeInputs.ContextIdentity();
return true;
}
bool DualBlockDriveClientVerbBoundary() {
// ReadPixels: a verb whose class mask carries the pixel-pack state the scenario dirties
// (so the emission step has a record to publish and the fixture's wire-moved assertion
// is fed), while the read_pixels VERB RECORD itself is never emitted - that is the GL
// entry point's job, not the validate point's - so nothing here reaches the server's
// stamp path (Clear / DrawVbo / ReadPixels / Blit) and the server block's serial is
// provably not this call's to move.
MobileGL::MG_Pipe::MGPipeValidateForVerb(MobileGL::MG_Pipe::MGPipeVerb::ReadPixels);
MobileGL::MG_Pipe::MGPipeLeaveVerb();
return true;
}
#else
bool PeekDualBlockState(DualBlockPeekState*) { return false; }
bool DualBlockDriveClientVerbBoundary() { return false; }
#endif
} // namespace MGITest
@@ -0,0 +1,54 @@
// MobileGL - MobileGL/MG_IntegrationTest/Harness/DualBlockPeek.h
// Copyright (c) 2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// P5f (f1), P5F-WIRE-COMPLETENESS.md §4/§6: THE DUAL-BLOCK REHEARSAL, READ FROM THE RUNNING
// PROCESS. The lane's negative control is a scenario that is green exactly when the two roles
// hold two DISTINCT PipeInputs blocks, and "the blocks are distinct" is a fact about pointers
// and serials inside the library - so, like SplitRuntimePeek, the reading is taken from the
// process and never inferred from the environment the entry was launched with.
//
// A SEPARATE TRANSLATION UNIT for PipeApplyPeek's reason, verbatim: the scenario sources
// include the GL headers with prototypes and PipeInputs.h is not meant to meet them in one
// file.
//
// PeekDualBlockState returns false, touching nothing, where it cannot look: a build without
// MOBILEGL_BUILD_DISAGGREGATED (the block split is compiled there only), or Android, where
// this module links the shipping libMobileGL.so built -fvisibility=hidden. A caller that gets
// false has learned NOTHING and must skip, not fail.
#pragma once
namespace MGITest {
struct DualBlockPeekState {
// False wherever the peek cannot look (see the header above). Everything else is
// meaningful only when this is true.
bool peekAvailable = false;
// MGPipeRoleSplitActive(): the knob AND a real transport. The control's red half turns
// on this answer alone.
bool roleSplitActive = false;
// &MGPipeClientInputs() != &gPipeInputs - the fill side and the read side are two
// different objects.
bool blocksDistinct = false;
// The two blocks' CurrentVerbSerial. The client block's advances with the fill side's
// verb boundaries; the server block's moves only on a server verb stamp.
unsigned long long clientSerial = 0;
unsigned long long serverSerial = 0;
// gPipeInputs.ContextIdentity(): non-null once the server has stamped a verb boundary
// under the rehearsal (CONTRACT-P5E §3.2's server-owned identity).
const void* serverIdentity = nullptr;
};
bool PeekDualBlockState(DualBlockPeekState* out);
// Drives ONE client-side verb boundary - MGPipeValidateForVerb + MGPipeLeaveVerb - with no
// draw behind it: exactly the fill side's write shape (serial bump, fill, verb set), and
// nothing that stamps the server block. Returns false where the peek cannot look.
bool DualBlockDriveClientVerbBoundary();
} // namespace MGITest
@@ -0,0 +1,30 @@
# The Fatal{UnmigratedPipeInput} pairs the dual-block lane ACTUALLY PRODUCES under
# MOBILEGL_IPC_ROLE_SPLIT_STATE=1 + MOBILEGL_IPC_STRICT_ERRORS=1, one `<field>@<verb>` per line.
#
# P5f (f1), P5F-WIRE-COMPLETENESS.md §4. This is the strict-expected-markers.txt shape turned
# around: the strict lane ratchets ADMITTED pulls (debts a phase has agreed to leave standing);
# this file ratchets the FATAL census of the dual-block rehearsal, which is the full list of
# "this read only ever worked because both roles shared one gPipeInputs". Every entry in the
# census lane aborts on its FIRST such read, so the pairs below are first-blockers, and retiring
# one advances the entry to its next one - the file shrinks package by package (fm/fs/fr/fv)
# and P5f's exit is this file empty with the lane green.
#
# TWO-SIDED, same rule as the strict file (ID-119): split_log_paths.py's `expect-fatal` mode
# fails when a pair appears that is not listed here AND when a pair listed here no longer
# appears. Remove a row in the commit that retires it.
#
# HOW TO REFRESH (never by hand from a guess):
# ctest --test-dir build-split -L 'integration-split|integration-magma-split' \
# --show-only=json-v1 > lane.json
# # reset the private logs, then:
# MOBILEGL_IPC_ROLE_SPLIT_STATE=1 MOBILEGL_IPC_STRICT_ERRORS=1 \
# ctest --test-dir build-split -L 'integration-split|integration-magma-split' -j 4 \
# --output-junit dualblock.xml
# python3 MobileGL/MG_IntegrationTest/Harness/split_log_paths.py expect-fatal \
# lane.json dualblock.xml this-file
# and put the census it prints here.
#
# ---------------------------------------------------------------------------------------------
# INITIAL CENSUS (f1, measured on the WSL gate tree - see docs/Disaggregated/notes/p5f/
# f1-report.md for the run):
# ---------------------------------------------------------------------------------------------
@@ -113,6 +113,107 @@ def print_marker_table(title, table):
print(f" {len(table[pair]):4d} {pair}")
def expect_fatal(document, junit_path, expected_path):
"""THE DUAL-BLOCK LANE'S CENSUS AND TWO-SIDED RATCHET (P5f f1, P5F §4/§6).
The markers mode above ratchets the strict lane's ADMITTED pairs; the dual-block lane's
currency is the FATAL pair, because under MOBILEGL_IPC_ROLE_SPLIT_STATE=1 every
BARRIER-PULLED read aborts unconditionally (CountBarrierPull's dual-block arm) - the red
IS the list of fields that still read the other role's memory.
Three checks, all load-bearing:
1. EVERY failed entry with a private log carries at least one Fatal{UnmigratedPipeInput}
marker in it. A red entry without one is an UNNAMED crash - a segfault or an abort
from something else - which is precisely what the rehearsal must make impossible
(the O-class fields have no null check; without the Fatal arm they die nameless).
2. No ADMITTED marker anywhere: under the dual-block arm the Fatal fires before the
admission question is asked, so an Admitted line means the arm did not fire.
3. The Fatal pair set equals the committed expected file, BOTH ways - a new pair is a
debt somebody now owes, a vanished pair means a later f package retired it and the
file must lose the row in the same commit (the strict file's rule, ID-119).
A green entry needs no marker and asserts nothing; the lane turning fully green with an
empty expected file IS the P5f exit state, which this mode then verifies rather than
obstructs."""
logs = marker_log_paths(document)
cases = ET.parse(junit_path).getroot().findall(".//testcase")
by_name = {}
for case in cases:
by_name.setdefault(case.get("name"), []).append(case)
problems = []
fatal, admitted, escalated = {}, {}, {}
scanned = 0
skipped = 0
green = 0
red = 0
for name, entries in sorted(by_name.items()):
if any(c.find("skipped") is not None for c in entries):
skipped += 1
continue
failed = any(c.find("failure") is not None or c.get("status") == "fail"
for c in entries)
if not failed:
green += 1
continue
red += 1
path = logs.get(name)
if path is None:
problems.append(f"{name} is RED and declares no MOBILEGL_LOG_FILE_PATH, so the "
"red cannot be shown to be a named Fatal - an unnamed crash is "
"exactly what this lane exists to forbid")
continue
if not Path(path).is_file():
problems.append(f"{name} is RED and its private log {path} does not exist - the "
"process died before it could write one, which is an unnamed crash")
continue
scanned += 1
text = Path(path).read_text(errors="replace")
entry_fatal = set()
for tag, field, verb, why in MARKER_RE.findall(text):
pair = f"{field}@{verb}"
if tag == "Fatal":
entry_fatal.add(pair)
fatal.setdefault(pair, set()).add(name)
elif (why or "").strip() == "ADMITTED-ESCALATED":
escalated.setdefault(pair, set()).add(name)
else:
admitted.setdefault(pair, set()).add(name)
if not entry_fatal:
problems.append(f"{name} is RED but its private log carries no "
"Fatal{UnmigratedPipeInput} marker - an unnamed failure, not a "
"census entry")
print(f"SplitLogPaths expect-fatal: {len(by_name)} entrie(s) in the run - "
f"{green} green, {skipped} skipped, {red} red ({scanned} with a private log scanned)")
print_marker_table("Fatal{UnmigratedPipeInput", fatal)
if admitted or escalated:
problems.append("%d Admitted marker(s) under the dual-block knob - the unconditional "
"Fatal arm in CountBarrierPull did not fire first: %s"
% (len(admitted) + len(escalated),
", ".join(sorted(set(admitted) | set(escalated)))))
expected = read_pair_set(expected_path)
observed = set(fatal)
appeared = sorted(observed - expected)
vanished = sorted(expected - observed)
if appeared:
problems.append("%d Fatal pair(s) the lane has not seen before: %s. Each is a field "
"that reads the other role's memory - add it to %s with the package "
"that retires it, or retire it."
% (len(appeared), ", ".join(appeared), Path(expected_path).name))
if vanished:
problems.append("%d expected pair(s) no longer appear: %s. Remove them from %s in the "
"commit that retired them - an expected set that keeps rows nothing "
"writes any more is how this lane rots green."
% (len(vanished), ", ".join(vanished), Path(expected_path).name))
if problems:
raise ValueError("the dual-block lane's fatal ratchet: " + " | ".join(problems))
print(f"SplitLogPaths expect-fatal: ratchet OK - {len(observed)} fatal pair(s), "
f"zero admitted, every red entry named")
def markers(document, selector, allowed_path, expected_path, require_no_fatal):
"""THE TWO-SIDED RATCHET (ID-119).
@@ -190,6 +291,9 @@ def main():
markers(json.loads(Path(sys.argv[2]).read_text()), sys.argv[3], sys.argv[4], sys.argv[5],
len(sys.argv) > 6 and sys.argv[6] == "no-fatal")
return
if mode == "expect-fatal":
expect_fatal(json.loads(Path(sys.argv[2]).read_text()), sys.argv[3], sys.argv[4])
return
selected = paths(json.loads(Path(sys.argv[2]).read_text()))
selected = {name: path for name, path in selected.items() if re.search(sys.argv[3], name)}
# The SAME exclusion run_control hands ctest -E. Without it this helper and ctest disagree
@@ -0,0 +1,93 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/DualBlockScenario.cpp
// Copyright (c) 2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// P5f (f1), P5F-WIRE-COMPLETENESS.md §6: THE DUAL-BLOCK MECHANISM'S NEGATIVE CONTROL, and it is
// deterministic rather than observational. "Give the two roles one PipeInputs block each"
// proves nothing if no case can tell one block from two, so this scenario asserts exactly that,
// from inside the process:
//
// 1. MGPipeRoleSplitActive() - the knob is armed AND the transport is real;
// 2. &MGPipeClientInputs() != &gPipeInputs - two distinct objects;
// 3. a client verb boundary moves the CLIENT block's serial and leaves the SERVER block's
// serial alone - the fill side's writes no longer reach the read side.
//
// With MOBILEGL_IPC_ROLE_SPLIT_STATE unset every one of the three fails by name - the block is
// shared, so (2) is false by construction and (3)'s "server serial does not move" fails because
// there is no second serial. The lane entry deliberately does NOT pin the knob in its own
// ENVIRONMENT: the gate's dualblock step exports MOBILEGL_IPC_ROLE_SPLIT_STATE=1 for the green
// form and scripts/ci/dualblock_negative_control.sh runs the same entry with it off for the red
// form, and a ctest ENVIRONMENT property would override the process environment in BOTH runs and
// make the red form unreachable. A bare `ctest -L integration-dualblock-split` without the knob
// is red, which is the honest answer for a lane whose name promises the split.
//
// No draw, clear or readback: under the armed knob every BARRIER-PULLED read is a named Fatal
// by design (that census is the lane's other half), and this case is the control - it must be
// GREEN on the armed arm. The lane pins MOBILEGL_IPC_RUN_AHEAD=0 so the fill decision is not a
// third variable: every verb boundary fills, deterministically.
#include "../Harness/DualBlockPeek.h"
#include "../Harness/ScenarioFixture.h"
#include "../Harness/SplitRuntimePeek.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
class DualBlockScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
const auto why = SplitRuntimeSkipReason();
if (!why.empty()) GTEST_SKIP() << why;
}
};
TEST_F(DualBlockScenario, TheTwoRolesHaveDistinctBlocks) {
if (!Ready()) return;
DualBlockPeekState before;
ASSERT_TRUE(PeekDualBlockState(&before))
<< "the dual-block peek is compiled out: this entry is only ever registered in a "
"disaggregated build, so a false here means the registration and the build disagree";
ASSERT_TRUE(before.roleSplitActive)
<< "dual-block control: MGPipeRoleSplitActive() is false - MOBILEGL_IPC_ROLE_SPLIT_STATE "
"is not armed in this process (or the transport is monolith), so the two roles share "
"one PipeInputs block and this case is the knob's negative control";
EXPECT_TRUE(before.blocksDistinct)
<< "dual-block control: MGPipeClientInputs() and gPipeInputs are the SAME object with "
"the rehearsal armed - the fill side and the read side were never split";
EXPECT_NE(before.serverIdentity, nullptr)
<< "dual-block control: the server block's identity was never set (CONTRACT-P5E §3.2's "
"server-owned SetIdentity) - the backend's identity-keyed memo caches read nullptr "
"as a hit on their zero-initialised slot";
// One client verb boundary, driven directly so no draw reaches the server: the CLIENT
// block's serial must move and the SERVER block's must not. Under a shared block the second
// half is impossible, which is the control's other edge. The glPixelStorei dirties the
// pixel-pack state so the boundary's emission step has a record to publish - the fixture's
// destructor asserts the wire moved on every armed split case.
glPixelStorei(GL_PACK_ALIGNMENT, 4);
ASSERT_TRUE(DualBlockDriveClientVerbBoundary());
DualBlockPeekState after;
ASSERT_TRUE(PeekDualBlockState(&after));
EXPECT_GT(after.clientSerial, before.clientSerial)
<< "dual-block control: a client verb boundary did not move the client block's serial";
EXPECT_EQ(after.serverSerial, before.serverSerial)
<< "dual-block control: a fill-side verb boundary moved the SERVER block's serial "
<< before.serverSerial << " -> " << after.serverSerial
<< " - the two roles are still writing one block";
}
} // namespace
} // namespace MGITest
@@ -1145,6 +1145,12 @@ namespace MobileGL::MG_Remote::Server {
m_verbs.SetBackend(backend);
m_decoder = Wire::PipeWireDecoder(control, m_segments, m_replies);
m_decoder.SetVerbSink(&m_verbs);
// P5f (f1), CONTRACT-P5E §3.2: the server block's identity, once per session (the
// per-verb stamp refreshes it against the served-context serial). A no-op unless the
// dual-block rehearsal is armed; without it the server block's ContextIdentity() stays
// nullptr and the backend's identity-keyed memo caches read that as a HIT on their
// zero-initialised slot - an unnamed null dereference instead of a named marker.
MG_Pipe::MGPipeServerBlockNoteIdentity();
m_attached = true;
}
@@ -865,6 +865,104 @@ TEST_F(FieldOwnershipTest, ResidualPullsReachThePublishedPerFrameCounter) {
PS::SetEnabledForTesting(false);
}
// ================================================================================
// P5f (f1): THE DUAL-BLOCK REHEARSAL AT THE BLOCK LEVEL (P5F-WIRE-COMPLETENESS.md §4)
// ================================================================================
//
// MOBILEGL_IPC_ROLE_SPLIT_STATE=1 gives the fill side its own PipeInputs block
// (MGPipeClientInputs()) and leaves gPipeInputs to the server alone. These cases pin the
// mechanism's three arms without a transport running: the selection folds to the shared block
// when the knob is off OR the transport is monolith, the two blocks are distinct objects when
// it is armed, and a BARRIER-PULLED read under it is a NAMED Fatal with no strict knob
// involved (the fork cases below).
namespace {
// Arms the rehearsal by hand - the knob is parsed from the environment once per process,
// so a case sets the two globals directly, and the guard puts them back on every exit
// path, ASSERT death included.
struct RoleSplitArm {
RoleSplitArm() {
m_previousTransport = MG_Config::Transport;
m_previousKnob = MG_Config::Ipc.RoleSplitState;
}
~RoleSplitArm() {
MG_Config::Transport = m_previousTransport;
MG_Config::Ipc.RoleSplitState = m_previousKnob;
}
void Arm(Bool arm) {
MG_Config::Ipc.RoleSplitState = arm;
MG_Config::Transport = arm ? MG_Config::TransportMode::InProcess
: MG_Config::TransportMode::Monolith;
}
MG_Config::TransportMode m_previousTransport;
Bool m_previousKnob;
};
} // namespace
TEST_F(FieldOwnershipTest, RoleSplitOffFoldsTheFillSideOntoTheSharedBlock) {
EXPECT_FALSE(MGPipeRoleSplitActive());
EXPECT_EQ(&MGPipeClientInputs(), &gPipeInputs);
}
// The knob alone is not the arm: under monolith transport the two roles are one thread and
// there is exactly one block, or every read would starve. This is the fold the whole unit
// lane and the integration-gpu lane of a split build stand on.
TEST_F(FieldOwnershipTest, RoleSplitUnderMonolithTransportIsStillOneBlock) {
RoleSplitArm guard;
MG_Config::Ipc.RoleSplitState = true;
MG_Config::Transport = MG_Config::TransportMode::Monolith;
EXPECT_FALSE(MGPipeRoleSplitActive());
EXPECT_EQ(&MGPipeClientInputs(), &gPipeInputs);
}
TEST_F(FieldOwnershipTest, RoleSplitGivesTheFillSideADistinctBlockTheStampNeverTouches) {
RoleSplitArm guard;
guard.Arm(true);
ASSERT_TRUE(MGPipeRoleSplitActive());
ASSERT_NE(&MGPipeClientInputs(), &gPipeInputs);
const Uint64 serverSerialBefore = gPipeInputs.FilledState().CurrentVerbSerial;
const Uint64 clientSerialBefore = MGPipeClientInputs().FilledState().CurrentVerbSerial;
MGPipeServerStampVerbBoundary(MGPipeVerb::DrawArrays);
EXPECT_EQ(gPipeInputs.FilledState().CurrentVerbSerial, serverSerialBefore + 1);
EXPECT_EQ(MGPipeClientInputs().FilledState().CurrentVerbSerial, clientSerialBefore)
<< "the server's verb stamp reached into the client block";
EXPECT_TRUE(gPipeInputs.ServerStampedVerb());
EXPECT_FALSE(MGPipeClientInputs().ServerStampedVerb());
// The fill side's clear is the client block's own: it must not disarm the server's stamp,
// which is CONTRACT-P5E §3.2's "the server's stamp is server-private" as a fact rather
// than as a comment.
MGPipeClientClearVerbBoundary();
EXPECT_TRUE(gPipeInputs.ServerStampedVerb())
<< "the client-role clear withdrew the SERVER's stamp";
MGPipeServerClearVerbBoundary();
EXPECT_FALSE(gPipeInputs.ServerStampedVerb());
// And the server block's identity is server-owned under the rehearsal (§3.2's other half):
// the stamp set it, and it is non-null - a null identity reads as a hit against
// DirectGLES' zero-initialised fb-slot memo cache, which is the unnamed crash this line
// exists to preclude.
EXPECT_NE(gPipeInputs.ContextIdentity(), nullptr);
}
// The fill side's writers land in the client block alone. MGPipeLeaveVerb rather than
// MGPipeValidateForVerb: the validate point's step 3 EMITS, which is session machinery this
// process does not have, while LeaveVerb's serial bump and verb reset are exactly the fill
// side's write shape with nothing else in the way.
TEST_F(FieldOwnershipTest, TheClientVerbLeaveWritesTheClientBlockAlone) {
RoleSplitArm guard;
guard.Arm(true);
ASSERT_NE(&MGPipeClientInputs(), &gPipeInputs);
const Uint64 serverSerialBefore = gPipeInputs.FilledState().CurrentVerbSerial;
const Uint64 clientSerialBefore = MGPipeClientInputs().FilledState().CurrentVerbSerial;
MGPipeLeaveVerb();
EXPECT_EQ(MGPipeClientInputs().FilledState().CurrentVerbSerial, clientSerialBefore + 1);
EXPECT_EQ(gPipeInputs.FilledState().CurrentVerbSerial, serverSerialBefore)
<< "a fill-side write reached the server block";
EXPECT_EQ(MGPipeClientInputs().CurrentVerb(), MGPipeVerb::kVerbCount);
}
#if MGTEST_HAVE_FORK
// R-7.3's proof that the instrumentation can go red. An instrumentation that cannot is
@@ -1117,6 +1215,42 @@ TEST_F(FieldOwnershipTest, AnUnmigratedEmulationIsFatalUnderARealTransportAndIne
<< split.Log;
}
// P5f (f1): THE REHEARSAL'S FATAL ARM, AND THE PROOF IT NEEDS NO OTHER KNOB. Under
// MOBILEGL_IPC_ROLE_SPLIT_STATE=1 a BARRIER-PULLED read is Fatal{UnmigratedPipeInput} with
// StrictErrors FALSE - the server block has no residual fill to answer from, so the read has
// no legal value whatever the strict knob says, and the marker names the knob that armed it.
// This is also the red-once for the lane's "no unnamed crashes" gate: the read below is an
// O-class SharedPtr field, which without this arm would hand the backend an empty pointer to
// dereference.
TEST_F(FieldOwnershipTest, DualBlockMakesABarrierPulledReadANamedAbortWithoutStrict) {
const ChildResult r = RunInChild([] {
MG_Config::Transport = MG_Config::TransportMode::InProcess;
MG_Config::Ipc.RoleSplitState = true;
MGPipeServerStampVerbBoundary(MGPipeVerb::DrawArrays);
(void)gPipeInputs.GetBoundVertexArray();
});
ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log;
EXPECT_NE(r.Log.find("Fatal{UnmigratedPipeInput, \"GetBoundVertexArray@DrawArrays\"}"),
std::string::npos)
<< r.Log;
EXPECT_NE(r.Log.find("BARRIER-PULLED"), std::string::npos) << r.Log;
EXPECT_NE(r.Log.find("MOBILEGL_IPC_ROLE_SPLIT_STATE=1"), std::string::npos) << r.Log;
}
// ... and the same read with the knob set but the transport MONOLITH is the folded arm: one
// block, the pull counted, no Fatal. The knob is not the mechanism; the knob AND a real
// transport are.
TEST_F(FieldOwnershipTest, DualBlockDoesNotArmUnderMonolithTransport) {
const ChildResult r = RunInChild([] {
MG_Config::Ipc.RoleSplitState = true; // the knob, with no transport behind it
MGPipeServerStampVerbBoundary(MGPipeVerb::DrawArrays);
(void)gPipeInputs.GetBoundVertexArray();
if (MGPipeResidualPullCount() != 1) ::_exit(7);
});
ASSERT_TRUE(ExitedWith(r, 0)) << DescribeStatus(r) << "\n" << r.Log;
EXPECT_EQ(r.Log.find("Fatal{"), std::string::npos) << r.Log;
}
#endif // MGTEST_HAVE_FORK
#endif // MOBILEGL_BUILD_DISAGGREGATED
#endif // MOBILEGL_PIPE_PUSH
+72
View File
@@ -0,0 +1,72 @@
#!/bin/bash
# P5f (f1)'s NEGATIVE CONTROL (P5F-WIRE-COMPLETENESS.md §6): the dual-block rehearsal must be
# the thing the dual-block lane measures.
#
# The contract is split_negative_controls.sh's, shrunk to one control because there is one
# knob:
#
# 1. THE BASELINE MUST BE GREEN. DirectGLES.Split.DualBlock.* runs with
# MOBILEGL_IPC_ROLE_SPLIT_STATE=1 exported (the entry deliberately does NOT pin the knob in
# its ctest ENVIRONMENT - a property would override the process environment and make the
# red form below unreachable, see its registration block). A skipped baseline is not a
# green one (ID-62), so skips are a hard error here, not a pass.
# 2. THE RED MUST CARRY THE SELECTED CASE'S OWN FAILURE TEXT. With the knob off the two roles
# share one block again and DualBlockScenario.TheTwoRolesHaveDistinctBlocks must fail on
# its own "dual-block control:" assertion - a timeout, a setup abort or an unrelated
# failure proves nothing about the knob (ID-46 finding 8).
#
# Usage: dualblock_negative_control.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}"
label="integration-dualblock-split"
tally_tool="$(dirname "$0")/junit_tally.py"
matched=$("${CTEST}" -N -L "${label}" | grep -cE '^ *Test *#[0-9]+:')
if [ "${matched}" -lt 1 ]; then
echo "::error::the dual-block lane selected ${matched} tests; its label no longer matches anything"
exit 1
fi
# ---- the baseline: knob ON, and it must be GREEN ------------------------------------------
baseline_xml="${CONTROL_TMPDIR}/dualblock-baseline.xml"
rm -f "${baseline_xml}"
MOBILEGL_IPC_ROLE_SPLIT_STATE=1 "${CTEST}" -L "${label}" --no-tests=error \
--output-on-failure --output-junit "${baseline_xml}"
baseline_rc=$?
tally=$(python3 "${tally_tool}" "${baseline_xml}")
baseline_passed=$(echo "${tally}" | cut -d' ' -f1)
baseline_failed=$(echo "${tally}" | cut -d' ' -f2)
baseline_skipped=$(echo "${tally}" | cut -d' ' -f3)
echo "dual-block baseline (knob on) - passed: ${baseline_passed}, failed: ${baseline_failed}, skipped: ${baseline_skipped}"
if [ "${baseline_rc}" -ne 0 ] || [ "${baseline_failed}" -gt 0 ]; then
echo "::error::the dual-block lane is ALREADY RED with the knob armed, so turning it off below proves nothing. Fix the lane first."
exit 1
fi
if [ "${baseline_passed}" -lt 1 ] || [ "${baseline_skipped}" -gt 0 ]; then
echo "::error::the dual-block baseline skipped or ran nothing - a control whose armed arm never executed is not a control (ID-62)"
exit 1
fi
# ---- the control: knob OFF, and the red must be the case's own assertion -------------------
control_xml="${CONTROL_TMPDIR}/dualblock-control.xml"
out="${CONTROL_TMPDIR}/dualblock-control-output.txt"
rm -f "${control_xml}"
control_rc=0
MOBILEGL_IPC_ROLE_SPLIT_STATE=0 "${CTEST}" -L "${label}" --no-tests=error \
--output-on-failure --output-junit "${control_xml}" > "${out}" 2>&1 || control_rc=$?
cat "${out}"
if [ "${control_rc}" -eq 0 ]; then
echo "::error::MOBILEGL_IPC_ROLE_SPLIT_STATE=0 left the dual-block lane GREEN: the two roles shared one block and nothing noticed - the mechanism is not load-bearing (P5F §6)"
exit 1
fi
if ! grep -q 'dual-block control:' "${out}"; then
echo "::error::the knob-off run is red but carries none of the case's own 'dual-block control:' assertions - the failure is something else wearing the control's name"
exit 1
fi
echo "negative control P5f-f1 (MOBILEGL_IPC_ROLE_SPLIT_STATE=0) turned ${matched} dual-block entries red, and the red is the case's own assertion, as it must"