diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3727fffa..001a86aa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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, "@"}. 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 diff --git a/MobileGL/Config.h b/MobileGL/Config.h index c4bb4db6..27807e22 100644 --- a/MobileGL/Config.h +++ b/MobileGL/Config.h @@ -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, "@"} 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. diff --git a/MobileGL/ConfigLoader.cpp b/MobileGL/ConfigLoader.cpp index 5d4d6a9d..a9686a22 100644 --- a/MobileGL/ConfigLoader.cpp +++ b/MobileGL/ConfigLoader.cpp @@ -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(ipc.StrictErrors), static_cast(ipc.Audit), - ipc.ServerAffinity.c_str()); + static_cast(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 " diff --git a/MobileGL/MG_Backend/MGPipe/PipeInputs.cpp b/MobileGL/MG_Backend/MGPipe/PipeInputs.cpp index 40d37938..d8bcba59 100644 --- a/MobileGL/MG_Backend/MGPipe/PipeInputs.cpp +++ b/MobileGL/MG_Backend/MGPipe/PipeInputs.cpp @@ -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(static_cast( + (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); } diff --git a/MobileGL/MG_Backend/MGPipe/PipeInputs.h b/MobileGL/MG_Backend/MGPipe/PipeInputs.h index 26d3daf9..6f7cf80f 100644 --- a/MobileGL/MG_Backend/MGPipe/PipeInputs.h +++ b/MobileGL/MG_Backend/MGPipe/PipeInputs.h @@ -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, "@"} (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, diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp index 72ff9c24..3769e5f6 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.cpp +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -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(field)], MGPipeVerbName(gPipeInputs.CurrentVerb()), + kMGPipeInputFieldNames[static_cast(field)], MGPipeVerbName(MGPipeClientInputs().CurrentVerb()), static_cast(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(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(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); } diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 87e49e2e..1f2a80d4 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -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, "@"} - 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() diff --git a/MobileGL/MG_IntegrationTest/Harness/DualBlockPeek.cpp b/MobileGL/MG_IntegrationTest/Harness/DualBlockPeek.cpp new file mode 100644 index 00000000..87d062aa --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/DualBlockPeek.cpp @@ -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 +#if MOBILEGL_PIPE_PUSH +#include +#include +// 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 diff --git a/MobileGL/MG_IntegrationTest/Harness/DualBlockPeek.h b/MobileGL/MG_IntegrationTest/Harness/DualBlockPeek.h new file mode 100644 index 00000000..20c475e9 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/DualBlockPeek.h @@ -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 diff --git a/MobileGL/MG_IntegrationTest/Harness/dualblock-expected-fatals.txt b/MobileGL/MG_IntegrationTest/Harness/dualblock-expected-fatals.txt new file mode 100644 index 00000000..0b230f0c --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/dualblock-expected-fatals.txt @@ -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 `@` 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): +# --------------------------------------------------------------------------------------------- diff --git a/MobileGL/MG_IntegrationTest/Harness/split_log_paths.py b/MobileGL/MG_IntegrationTest/Harness/split_log_paths.py index 9299a5ae..988bfbea 100644 --- a/MobileGL/MG_IntegrationTest/Harness/split_log_paths.py +++ b/MobileGL/MG_IntegrationTest/Harness/split_log_paths.py @@ -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 diff --git a/MobileGL/MG_IntegrationTest/Scenarios/DualBlockScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/DualBlockScenario.cpp new file mode 100644 index 00000000..3206232c --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/DualBlockScenario.cpp @@ -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 +#include +#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 diff --git a/MobileGL/MG_Remote/Server/PipeApplier.cpp b/MobileGL/MG_Remote/Server/PipeApplier.cpp index f9c4fa22..3c9f557f 100644 --- a/MobileGL/MG_Remote/Server/PipeApplier.cpp +++ b/MobileGL/MG_Remote/Server/PipeApplier.cpp @@ -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; } diff --git a/MobileGL/MG_Test/Pipe/FieldOwnershipTest.cpp b/MobileGL/MG_Test/Pipe/FieldOwnershipTest.cpp index 1dc143e2..aabd719d 100644 --- a/MobileGL/MG_Test/Pipe/FieldOwnershipTest.cpp +++ b/MobileGL/MG_Test/Pipe/FieldOwnershipTest.cpp @@ -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 diff --git a/scripts/ci/dualblock_negative_control.sh b/scripts/ci/dualblock_negative_control.sh new file mode 100644 index 00000000..1e7335b3 --- /dev/null +++ b/scripts/ci/dualblock_negative_control.sh @@ -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"