diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e6371b80..0afb10f0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,4 @@ -name: Test +name: Test on: push: @@ -1116,6 +1116,121 @@ jobs: path: /tmp/core.* if-no-files-found: ignore + # P5c gt (CONTRACT-P5C §6): the strict twin of integration-split. The role guards (the two + # layers of Fatal{RoleViolation, ...}) are COMPILED into every split build - there is no + # knob to arm them - so "guards armed" below means MOBILEGL_IPC_STRICT_ERRORS=1, the knob + # that promotes a BARRIER-PULLED residual read from counted debt to a named abort. + # + # TWO ASSERTIONS, NOT ONE, because the two halves of "strict is armed" are different + # statements: + # + # * the unit label must stay GREEN under it. A unit case that newly aborts under strict + # is a value-class read that crept back in (P5c rv's exit line is value-class = 0), and + # this step is what says so. FieldOwnershipTest owns the strict arms' behaviour cases; + # + # * the integration-split scenarios must all ABORT under it, BY DESIGN: the remaining + # residual pulls are the pinned object-class rows (GetFramebufferBindingSlot & co., + # P3b/P4b/P7's), every drawing scenario reads one, and strict turns the first read into + # Fatal{UnmigratedPipeInput, ...} [BARRIER-PULLED, MOBILEGL_IPC_STRICT_ERRORS=1, ...]. + # A lane that "passed" a scenario here would mean strict never armed - the same + # two-state shape as the E1/E3(a) negative controls, so the step asserts the abort AND + # greps the entry's private split-log for the strict marker. This half flips meaning + # when the object-class rows retire (P7): the scenarios then PASS under strict and the + # step is rewritten as an ordinary green lane. + integration-split-strict: + runs-on: ubuntu-latest + timeout-minutes: 60 + needs: build-linux-split + + steps: + - name: Checkout repo + uses: actions/checkout@v6 + + - name: Get CMake + uses: lukka/get-cmake@v4.3.3 + + - name: Install runtime dependencies + run: | + sudo apt-get update + sudo apt-get install -y libvulkan1 libegl1 libegl-mesa0 libgles2 libgl1-mesa-dri mesa-vulkan-drivers + + - name: Download Linux split runtime + uses: actions/download-artifact@v8 + with: + name: mobilegl-linux-runtime-split + path: . + + - name: Unpack Linux split runtime + run: | + tar -xzf mobilegl-linux-runtime-split.tgz + test -f build-split/libMobileGL.so + + - name: Normalize CTest command paths + run: | + python - <<'PY' + from pathlib import Path + import re + + for path in Path('build-split').rglob('CTestTestfile.cmake'): + text = path.read_text() + text = re.sub(r'"[^"]*/cmake-[^"]*/bin/cmake"', '"cmake"', text) + path.write_text(text) + PY + + # The green half: no unit case may pull harder under strict. This includes the + # RemoteGuards suite (the layer-1/layer-2 Fatal drives) and FieldOwnershipTest's strict + # arms, so the guards are provably armed in the lane that gates on them. + - name: Unit tests on the split runtime under MOBILEGL_IPC_STRICT_ERRORS=1 + working-directory: build-split + env: + MOBILEGL_ITEST_REQUIRE_GPU: "1" + MOBILEGL_IPC_STRICT_ERRORS: "1" + run: ctest --output-on-failure -L unit --no-tests=error -j "$(nproc)" + + # The expected-red half: every integration-split entry aborts on its first BARRIER-PULLED + # read under strict. The run is EXPECTED to fail; what the step gates on is that the + # failure is the strict Fatal and nothing else. A scenario that PASSED would mean strict + # never armed, and one whose private log lacks the marker failed for another reason. + - name: Integration-split aborts under strict with the strict marker + working-directory: build-split + env: + MOBILEGL_ITEST_REQUIRE_GPU: "1" + MOBILEGL_IPC_STRICT_ERRORS: "1" + MOBILEGL_TRANSPORT: "inproc" + run: | + ulimit -c unlimited + rc=0 + ctest -L integration-split --no-tests=error -j 4 > "${RUNNER_TEMP}/strict-split.txt" 2>&1 || rc=$? + cat "${RUNNER_TEMP}/strict-split.txt" | tail -5 + if [ "${rc}" -eq 0 ]; then + echo "::error::integration-split under MOBILEGL_IPC_STRICT_ERRORS=1 went GREEN - the object-class residual pulls (P3b/P4b/P7) are still pinned, so strict must abort every drawing scenario. If the object-class rows have just retired, rewrite this step as the green lane it then becomes." + exit 1 + fi + failed=$(grep -cE '^ *[0-9]+ - .* \(.*(Failed|Subprocess aborted).*\)' "${RUNNER_TEMP}/strict-split.txt" || true) + # Every entry that RAN (did not skip) must have failed; entries skipped by a missing + # GPU or an unmet pre-flight are reported, not counted. + ran=$(grep -cE 'tests passed, [0-9]+ tests failed out of [0-9]+' "${RUNNER_TEMP}/strict-split.txt" || true) + echo "strict run: rc=${rc}" + # The strict marker, from the split entries' own private logs: the run's red must be + # the strict Fatal and not a bring-up defect. + marker='BARRIER-PULLED, MOBILEGL_IPC_STRICT_ERRORS=1' + hits=$(grep -rl "${marker}" MobileGL/MG_IntegrationTest/split-logs/ 2>/dev/null | wc -l) + if [ "${hits}" -lt 1 ]; then + echo "::error::no split entry's private log carries the strict marker '${marker}' - the lane went red without the strict Fatal firing, which is a defect, not the control" + exit 1 + fi + echo "integration-split under strict is red by design; ${hits} private log(s) carry the strict marker" + + - name: Upload strict lane logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: integration-split-strict-logs + path: | + build-split/MobileGL/MG_IntegrationTest/*.log* + build-split/MobileGL/MG_IntegrationTest/split-logs/*.log + if-no-files-found: warn + # MobileGL/MG_Remote/Protocol/generated/protocol_generated.h is COMMITTED, and # flatc is deliberately absent from the default build graph (a codegen step in # the graph is how the earlier branch ended up cross-compiling an arm64 flatc diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index 606a2577..3167495e 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -5892,6 +5892,41 @@ namespace MobileGL::MG_Backend::DirectGLES { "pending set - they would be allocated empty", stateTextureObject->GetExternalIndex()); } +#endif +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (gt): under an active transport the replay loop below reaches the layer-1 + // texture guard (MarkStorageDirty on a frontend object is Fatal{RoleViolation, + // "texture-legacy-arm"}) BEFORE the N-4 marker it owns - so the marker is hoisted + // here, ahead of the first guarded contact, with its semantics unchanged: raised + // only when a level would actually be replayed. The predicate is answered by the + // server's own staged-texture store rather than the frontend's extent walk: a + // Defined level has a non-zero extent and bytes on the frontend (the two conditions + // the loop tests), and an undefined one reads {0,0,0} and is skipped. A texture with + // no applier record (rearmRes null, the MGLOG_E_ONCE arm above) falls through to the + // guard, which names the same violation one level down. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith && + !MG_Pipe::MGPipeHandleIsNull(rearmRes)) { + const auto* rearmRecord = PipeTextureRecordForHandle(rearmRes); + if (rearmRecord != nullptr) { + auto& rearmStore = MG_Remote::Server::ServerStagedTexture(); + const Uint64 rearmKey = MG_Remote::Server::StagedTextureStore::KeyForHandle(rearmRes); + Bool anyLevelWouldReplay = false; + for (const auto& uploadTarget : + BufferImpl::StagedUploadTargetsForPipeTarget(rearmRecord->Desc.Target)) { + for (Uint32 level = 0; level < rearmRecord->Desc.Levels; ++level) { + if (rearmStore.IsLevelDefined(rearmKey, static_cast(uploadTarget), + static_cast(level))) { + anyLevelWouldReplay = true; + break; + } + } + if (anyLevelWouldReplay) break; + } + if (anyLevelWouldReplay) { + MG_Pipe::MGPipeUnmigratedEmulation("texture-remint-pull"); + } + } + } #endif if (auto* mipmapObject = MG_State::GLState::AsMipmapTexture(stateTextureObject.get())) { const auto levelCount = mipmapObject->GetMipmapLevelCount(); @@ -7153,6 +7188,48 @@ namespace MobileGL::MG_Backend::DirectGLES { // left the backend name with no levels whatsoever, so the level that WAS defined could // never be sampled or read back. Sync whenever some level holds an image; the per-level // loops below skip the degenerate ones individually. +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (gt, CONTRACT-P5C §6 layer 1): on the staged arm the same question is answered + // out of the SERVER's staged-texture store, never out of the frontend object - the + // store's Defined-ness (fed by the respecify hook and the sub-data adoption) IS "some + // level holds an image", and IsComplete()'s sampling half is redundant with it: a + // complete mipmap chain has a defined level 0, and the gate's whole job is to let an + // incomplete-but-partly-defined chain through. The one divergence is deliberate and + // stated: a chain whose EVERY level is 0x0 is complete-by-quirk on the frontend (the + // "0x0 in last level" relaxation, TextureObject.cpp) and undefined here - and syncing + // it would upload nothing either way, because every per-level loop below skips a + // {0,0,0} level individually. A BUFFER texture never has staged levels, so its gate + // is the descriptor's format - exactly what TextureObjectBase::IsComplete() reduces + // to for that storage kind. + if (MGB_STAGED_TEXTURE_LIVE) { + const auto& stagedDesc = pushedStorage->Desc; + Bool anyDefined = false; + if (static_cast(stagedDesc.StorageKind) == TextureStorageType::Buffer) { + // A buffer texture has no staged levels; its gate is the descriptor's + // format, which is what TextureObjectBase::IsComplete() reduces to here. + anyDefined = static_cast(stagedDesc.InternalFormat) != + TextureInternalFormat::Unknown; + } else { + auto& stagedStore = MG_Remote::Server::ServerStagedTexture(); + const Uint64 stagedKey = MG_Remote::Server::StagedTextureStore::KeyForHandle(pushedRes); + for (const auto& uploadTarget : BufferImpl::StagedUploadTargetsForPipeTarget(stagedDesc.Target)) { + for (Uint32 level = 0; level < stagedDesc.Levels; ++level) { + if (stagedStore.IsLevelDefined(stagedKey, static_cast(uploadTarget), + static_cast(level))) { + anyDefined = true; + break; + } + } + if (anyDefined) break; + } + } + if (!anyDefined) { + MGLOG_D("Texture object with ID: %u has no defined image level, skipping sync.", + stateTextureObject->GetExternalIndex()); + return; + } + } else +#endif if (!stateTextureObject->IsComplete() && !HasAnyDefinedMipmapLevel(stateTextureObject.get())) { MGLOG_D("Texture object with ID: %u has no defined image level, skipping sync.", stateTextureObject->GetExternalIndex()); @@ -7321,7 +7398,7 @@ namespace MobileGL::MG_Backend::DirectGLES { break; default: MGLOG_E_ONCE("Unhandled texture target %s", - MG_Util::ConvertTextureTargetToString(stateTextureObject->GetTarget()).c_str()); + MG_Util::ConvertTextureTargetToString(MGB_TEXTURE_TARGET(stateTextureObject)).c_str()); break; } DebugImpl::ErrorLopper::Loop([file = __FILE__, line = __LINE__, func = __func__, @@ -8127,7 +8204,7 @@ namespace MobileGL::MG_Backend::DirectGLES { break; default: MGLOG_E_ONCE("Unhandled texture target %s", - MG_Util::ConvertTextureTargetToString(stateTextureObject->GetTarget()).c_str()); + MG_Util::ConvertTextureTargetToString(MGB_TEXTURE_TARGET(stateTextureObject)).c_str()); break; } if (ringStaged) { diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp index d64ae924..47413792 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp @@ -1744,6 +1744,16 @@ namespace MobileGL::MG_Backend::DirectVulkan { Bool VkTextureManager::SyncTexture(MG_State::GLState::ITextureObject &texture, TextureResource &outResource) { +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (gt): Magma's texture sync still reads - and clears - the CLIENT's mip shadow: + // the dirty scan below, and UploadDirtyMipLevels' texel reads / region reads / + // MarkStorageDirty(false) clears. CONTRACT-P5C §2 migrated Espryt's sync and Magma's + // T5 writes, and left THIS path on the legacy arm; under the verb barrier and one + // address space the reads answer correctly, and the migration is P7's server-side + // sync. The scope is the debt's greppable form (MipmapStorage.h); outside it the + // layer-1 guard still aborts. + const MG_State::GLState::MGPipeTextureLegacyArmScope textureLegacyArm; +#endif // Cross-draw fast path: if the resource is already built and neither the texture's // pixel content (bumped in MarkStorageDirty), its SHAPE (bumped in BumpShapeVersion) // nor its params changed since the last sync, there is nothing to re-check or diff --git a/MobileGL/MG_Backend/MGPipe/PipeInputs.cpp b/MobileGL/MG_Backend/MGPipe/PipeInputs.cpp index 35caa907..23ede787 100644 --- a/MobileGL/MG_Backend/MGPipe/PipeInputs.cpp +++ b/MobileGL/MG_Backend/MGPipe/PipeInputs.cpp @@ -147,6 +147,23 @@ namespace MobileGL::MG_Pipe { if (!serverStamped || kMGPipeFieldOwnership[static_cast(field)] != MGPipeFieldOwnership::kBarrierPulled || !FieldIsInVerbClass(field, verb)) { + // P5c (gt, CONTRACT-P5C §6 layer 1): under a server stamp, a stale read of a field + // whose row is RECORD-SUPPLIED or APPLIER-DERIVED - i.e. a value a pushed record + // DOES carry, read where this verb's stamp does not cover it - is a role violation + // named by its surface, not a generic unmigrated read: the wire already owns the + // answer, so reaching past it into the residual fill is rule E's shape. A + // BARRIER-PULLED field outside the verb's class and a FATAL field keep the poison + // Fatal - those are the stamp table's own verdicts, not a role's overreach. + const MGPipeFieldOwnership ownership = kMGPipeFieldOwnership[static_cast(field)]; + if (serverStamped && (ownership == MGPipeFieldOwnership::kRecordSupplied || + ownership == MGPipeFieldOwnership::kApplierDerived)) { + MGLOG_F("MGPipe: Fatal{RoleViolation, \"%s\"} - the server read this field stale " + "at %s, but a pushed record carries it (the row is %s): the read reached " + "the client's residual fill for a value the wire already owns", + kMGPipeInputFieldNames[static_cast(field)], MGPipeVerbName(verb), + MGPipeFieldOwnershipName(ownership)); + std::abort(); + } MGPipeInputPoisonFatalForVerb(field, verb); } CountBarrierPull(field, verb); diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp index 6d6d1b04..252e0153 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.cpp +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -48,6 +48,9 @@ // lanes must keep answering exactly what they answered before. #include #include +// P5c gt (CONTRACT-P5C §6 layer 2, audit A1): the client-side gPipeInputs check consults +// InBarrierWait() and ApplyThreadIsInsideApplier() - both live here. +#include // P5c ev (CONTRACT-P5C §4.2): RecordError's transport arm posts kEventGlError through the // server session, and InvalidateCompileEnv's forward is deleted with an active transport. #include @@ -532,6 +535,12 @@ namespace MobileGL::MG_Pipe { // an unrelated frontend write. void MGPipeNoteFrontendMutation(MGPipeInputField field) { PipeInputs& inputs = gPipeInputs; +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (gt, layer 2): the single-field refresh is a client write into gPipeInputs too - + // same gate as the fill. + MG_Remote::Client::ClientSession::RefusePipeInputsTouchWhileApplierOwnsIt( + "MGPipeNoteFrontendMutation"); +#endif auto* ctx = LiveContext(); if (ctx == nullptr) return; const auto verb = inputs.CurrentVerb(); @@ -1827,6 +1836,11 @@ namespace MobileGL::MG_Pipe { void MGPipeLeaveVerb() { PipeInputs& inputs = gPipeInputs; +#if MOBILEGL_BUILD_DISAGGREGATED + // Same layer-2 gate as the fill: the serial bump and the verb reset below are writes + // into gPipeInputs (gt). + MG_Remote::Client::ClientSession::RefusePipeInputsTouchWhileApplierOwnsIt("MGPipeLeaveVerb"); +#endif #if MOBILEGL_PIPE_POISON // Same bump the next fill would make, without a verb to fill from: no field is // stamped, so every stamp this verb made falls behind the serial. @@ -2657,6 +2671,15 @@ namespace MobileGL::MG_Pipe { // ---- the validate point (P2 brief D1) ---- void MGPipeValidateForVerb(MGPipeVerb verb) { PipeInputs& inputs = gPipeInputs; +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (gt, CONTRACT-P5C §6 layer 2): the residual fill is THE client-side write into + // gPipeInputs, and it is legal only because it runs before the record is published - + // under an armed barrier the apply thread's in-applier flag is provably down here. If + // that ever stops being true this is the check that says so, rather than the applier + // reading a half-written fill. + MG_Remote::Client::ClientSession::RefusePipeInputsTouchWhileApplierOwnsIt( + "MGPipeValidateForVerb"); +#endif ParsePoisonOmissionKnob(); #if MOBILEGL_PIPE_VERIFY ArmVerify(); diff --git a/MobileGL/MG_Remote/Client/ClientSession.cpp b/MobileGL/MG_Remote/Client/ClientSession.cpp index dedca57d..ba2b9ad2 100644 --- a/MobileGL/MG_Remote/Client/ClientSession.cpp +++ b/MobileGL/MG_Remote/Client/ClientSession.cpp @@ -969,6 +969,25 @@ namespace MobileGL::MG_Remote::Client { Bool ClientSession::ApplyThreadIsInsideApplier() { return g_applyThreadInsideApplier.load(std::memory_order_acquire); } + + void ClientSession::RefusePipeInputsTouchWhileApplierOwnsIt(const char* surface) { + if (Server::ServerLoop::OnApplyThread()) return; // the applier owns the block inside a verb + if (MG_Config::Transport == MG_Config::TransportMode::Monolith) return; + if (!ClientSessionInstance().Started()) return; // the bring-up window pre-dates the roles + // MOBILEGL_IPC_VERB_BARRIER=0 is R-1's NEGATIVE CONTROL: the single-writer rule is off + // by the operator's own hand there, and EmitAndWait's Fatal{BarrierViolation} owns the + // red. Firing here instead would pre-empt the control's evidence line. + if (!ClientSessionInstance().BarrierArmed()) return; + if (!ApplyThreadIsInsideApplier()) return; + if (InBarrierWait()) return; + MGLOG_F("MGPipe: Fatal{RoleViolation, \"gPipeInputs\"} - the GL thread touched gPipeInputs " + "(%s) while the apply thread was inside the applier and this thread was not in a " + "barrier wait. R-1's barrier is the only thing that makes one process-wide " + "gPipeInputs legal (CONTRACT-P5 table 3); a touch in this window races the " + "applier's own reads of it", + surface); + std::abort(); + } void ClientSession::NoteApplyThreadEnteredApplier() { g_applyThreadInsideApplier.store(true, std::memory_order_release); } diff --git a/MobileGL/MG_Remote/Client/ClientSession.h b/MobileGL/MG_Remote/Client/ClientSession.h index 5f7d1afb..0c780ecb 100644 --- a/MobileGL/MG_Remote/Client/ClientSession.h +++ b/MobileGL/MG_Remote/Client/ClientSession.h @@ -128,6 +128,20 @@ namespace MobileGL::MG_Remote::Client { static Bool InBarrierWait(); static Bool ApplyThreadIsInsideApplier(); + // P5c (gt, CONTRACT-P5C §6 layer 2 / audit A1): the sentence above's second half, + // wired. A GL-thread touch of gPipeInputs while the apply thread is inside the + // applier and THIS thread is not in a barrier wait is Fatal{RoleViolation, + // "gPipeInputs"} - the barrier is the only thing that makes one process-wide + // gPipeInputs legal (CONTRACT-P5 table 3), and a touch in that window races the + // applier's own reads of it. The residual fill and the verify harness are legal by + // timing, not by exemption: they run before the record is published, which under an + // armed barrier is a moment the flag is provably down (the applier drops it before + // appliedSeq advances past the record the client last waited on). No-op when no armed + // split session is live, when the caller IS the apply thread, and when the barrier is + // disarmed - MOBILEGL_IPC_VERB_BARRIER=0 is R-1's negative control and its red belongs + // to Fatal{BarrierViolation}, not to this check. + static void RefusePipeInputsTouchWhileApplierOwnsIt(const char* surface); + // ---- c1's additions --------------------------------------------------------------- // The apply thread's half of R-1's invariant. v1's apply loop brackets its diff --git a/MobileGL/MG_State/GLState/TextureState/MipmapStorage.cpp b/MobileGL/MG_State/GLState/TextureState/MipmapStorage.cpp index 58affe99..921fb312 100644 --- a/MobileGL/MG_State/GLState/TextureState/MipmapStorage.cpp +++ b/MobileGL/MG_State/GLState/TextureState/MipmapStorage.cpp @@ -8,10 +8,50 @@ #include "MipmapStorage.h" +#if MOBILEGL_BUILD_DISAGGREGATED +#include +#include +#include + +#include +#endif + namespace MobileGL { namespace MG_State { namespace GLState { namespace { +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (gt, CONTRACT-P5C §6 layer 1): the frontend TextureObjectMipmap's + // mutable surfaces - the maps, the storage (re)definitions and the + // storage-dirty model - are a layer-1 surface, and MipmapStorage is the one + // funnel every concrete texture class reaches them through (the cube's six + // faces included, and a view's forwards land on its owner's storage). With an + // active transport the apply thread calling one is + // Fatal{RoleViolation, "texture-legacy-arm"} - the texture twin of hd's + // BufferObject guard (BufferObject.cpp): the server reads its own staged + // texture shadow and the descriptor (tx), never this memory. Client-thread + // callers (the GL thread's own state) are unaffected, and the guard compiles + // out of every non-split build, so the pull build's bytes do not move (G1). + // + // The message names the TEXTURE-side method, not this storage's: the pinned + // surface list (MG_Test/Wire/RemoteClientTest.cpp's RemoteGuards) is written + // in TextureObjectMipmap's vocabulary. + void RefuseLegacyTextureArmFromApplyThread(const char* surface) { + if (MG_Config::Transport == MG_Config::TransportMode::Monolith) return; + if (!MG_Remote::Server::ServerLoop::OnApplyThread()) return; + // The named exemption (MipmapStorage.h): Magma's texture sync is tx's + // declared leftover and retires with P7. + if (MGPipeTextureLegacyArmScope::Active()) return; + MGLOG_F("MGPipe: Fatal{RoleViolation, \"texture-legacy-arm\"} - the apply thread " + "called TextureObjectMipmap::%s on a frontend object. With an active " + "transport the server reads the staged-texture store and the resource " + "descriptor (CONTRACT-P5C §2, rule E); a frontend texture's level " + "storage is client memory and this arm is monolith-only", + surface); + std::abort(); + } +#endif + // Overlapping OR abutting ([lo, hi) intervals meeting edge-to-edge) in // every axis: merging abutting boxes keeps scanline/tile write patterns // as one rect instead of a picket fence. @@ -28,11 +68,32 @@ namespace MobileGL { } } // namespace +#if MOBILEGL_BUILD_DISAGGREGATED + namespace { + thread_local Uint32 g_textureLegacyArmScopeDepth = 0; + } + + MGPipeTextureLegacyArmScope::MGPipeTextureLegacyArmScope() { + ++g_textureLegacyArmScopeDepth; + } + + MGPipeTextureLegacyArmScope::~MGPipeTextureLegacyArmScope() { + --g_textureLegacyArmScopeDepth; + } + + Bool MGPipeTextureLegacyArmScope::Active() { + return g_textureLegacyArmScopeDepth != 0; + } +#endif + SizeT MipmapStorage::GetLevelCount() const { return m_data.size(); } void MipmapStorage::AllocateLevel(Uint level, MipmapInput input) { +#if MOBILEGL_BUILD_DISAGGREGATED + RefuseLegacyTextureArmFromApplyThread("AllocateStorage"); +#endif // Grow only. GL respecifies exactly the level it is handed, so allocating level 0 // must not disturb the levels above it - but resize() shrinks as readily as it // grows, so this used to truncate the whole chain to a single level. Callers that @@ -125,6 +186,9 @@ namespace MobileGL { } void MipmapStorage::TruncateToLevelCount(SizeT levelCount) { +#if MOBILEGL_BUILD_DISAGGREGATED + RefuseLegacyTextureArmFromApplyThread("TruncateMipmapLevels"); +#endif if (levelCount >= m_data.size()) return; m_data.resize(levelCount); @@ -138,6 +202,9 @@ namespace MobileGL { } void MipmapStorage::UpdateSubData(Uint level, DataPtr input) { +#if MOBILEGL_BUILD_DISAGGREGATED + RefuseLegacyTextureArmFromApplyThread("UpdateMipmapSubData"); +#endif auto& targetData = m_data; MOBILEGL_ASSERT(level < targetData.size(), "UpdateSubData: level out of range"); auto& levelData = targetData[level]; @@ -152,6 +219,9 @@ namespace MobileGL { } void* MipmapStorage::MapData(Uint level) { +#if MOBILEGL_BUILD_DISAGGREGATED + RefuseLegacyTextureArmFromApplyThread("MapMipmapData"); +#endif auto& targetData = m_data; MOBILEGL_ASSERT(level < targetData.size(), "UpdateSubData: level out of range"); auto& levelData = targetData[level]; @@ -170,6 +240,9 @@ namespace MobileGL { } void MipmapStorage::MarkDirty(Uint level, bool dirty) { +#if MOBILEGL_BUILD_DISAGGREGATED + RefuseLegacyTextureArmFromApplyThread("MarkStorageDirty"); +#endif MOBILEGL_ASSERT(level < m_isDirty.size(), "MarkDirty: level out of range"); m_isDirty[level] = dirty; if (level < m_dirtyRegions.size()) { @@ -191,11 +264,17 @@ namespace MobileGL { } bool MipmapStorage::IsDirty(Uint level) const { +#if MOBILEGL_BUILD_DISAGGREGATED + RefuseLegacyTextureArmFromApplyThread("IsStorageDirty"); +#endif MOBILEGL_ASSERT(level < m_isDirty.size(), "IsDirty: level out of range"); return m_isDirty[level]; } void MipmapStorage::MarkDirtyRegion(Uint level, IntVec3 offset, IntVec3 size) { +#if MOBILEGL_BUILD_DISAGGREGATED + RefuseLegacyTextureArmFromApplyThread("MarkStorageDirtyRegion"); +#endif MOBILEGL_ASSERT(level < m_isDirty.size(), "MarkDirtyRegion: level out of range"); const IntVec3 levelSize = level < m_texelSizes.size() ? m_texelSizes[level] : IntVec3{0, 0, 0}; MipmapDirtyRegion incoming; @@ -280,11 +359,17 @@ namespace MobileGL { } MipmapDirtyRegion MipmapStorage::GetDirtyRegion(Uint level) const { +#if MOBILEGL_BUILD_DISAGGREGATED + RefuseLegacyTextureArmFromApplyThread("GetStorageDirtyRegion"); +#endif if (level >= m_dirtyRegions.size()) return {}; return m_dirtyRegions[level]; } SizeT MipmapStorage::GetDirtyRects(Uint level, MipmapDirtyRegion* outRects, SizeT maxRects) const { +#if MOBILEGL_BUILD_DISAGGREGATED + RefuseLegacyTextureArmFromApplyThread("GetStorageDirtyRects"); +#endif if (outRects == nullptr || level >= m_dirtyRects.size() || level >= m_dirtyRegions.size()) { return 0; } diff --git a/MobileGL/MG_State/GLState/TextureState/MipmapStorage.h b/MobileGL/MG_State/GLState/TextureState/MipmapStorage.h index fe632db4..3762c8c8 100644 --- a/MobileGL/MG_State/GLState/TextureState/MipmapStorage.h +++ b/MobileGL/MG_State/GLState/TextureState/MipmapStorage.h @@ -38,6 +38,28 @@ namespace MobileGL { } }; +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (gt, CONTRACT-P5C §6 layer 1): the NAMED EXEMPTION to the texture legacy-arm + // guard, the texture analogue of hd's MGPipeFrontendKeyedRegistryScope + // (MG_Impl/Pipe/SlotAllocator.h). Magma's texture sync (VkTextureManager::SyncTexture + // and the UploadDirtyMipLevels it drives) still reads and clears the CLIENT's mip + // shadow - the one texture family tx did not migrate (CONTRACT-P5C §2 names Espryt's + // sync reads and Magma's T5 writes; Magma's upload read path is absent from it), and + // inproc was green on it because the barrier and the shared address space held. + // Inside this scope the guard's surfaces stay legal - read-only-or-clean, barrier- + // held, and greppable as exactly this name; retiring the scope is P7's server-side + // Magma texture sync. Every OTHER apply-thread touch of a guarded surface is still + // Fatal{RoleViolation, "texture-legacy-arm"}. + class MGPipeTextureLegacyArmScope { + public: + MGPipeTextureLegacyArmScope(); + ~MGPipeTextureLegacyArmScope(); + MGPipeTextureLegacyArmScope(const MGPipeTextureLegacyArmScope&) = delete; + MGPipeTextureLegacyArmScope& operator=(const MGPipeTextureLegacyArmScope&) = delete; + static Bool Active(); + }; +#endif + class MipmapStorage { public: SizeT GetLevelCount() const; diff --git a/MobileGL/MG_Test/Wire/RemoteClientTest.cpp b/MobileGL/MG_Test/Wire/RemoteClientTest.cpp index cb03dec1..0278b877 100644 --- a/MobileGL/MG_Test/Wire/RemoteClientTest.cpp +++ b/MobileGL/MG_Test/Wire/RemoteClientTest.cpp @@ -47,6 +47,7 @@ #include #include #include +#include #include #include #include @@ -1835,6 +1836,7 @@ MGL_BUFFER_GUARD_TEST(BufferIsMappedFromTheApplyThreadIsFatalByName, 704, (void) MGL_BUFFER_GUARD_TEST(BufferChangeSerialFromTheApplyThreadIsFatalByName, 705, (void)buffer.GetChangeSerial()) MGL_BUFFER_GUARD_TEST(BufferSyncPersistentMappedRangeFromTheApplyThreadIsFatalByName, 706, buffer.SyncPersistentMappedRange()) +MGL_BUFFER_GUARD_TEST(BufferHasDefinedContentFromTheApplyThreadIsFatalByName, 707, (void)buffer.HasDefinedContent()) #undef MGL_BUFFER_GUARD_TEST TEST(RemoteGuards, CapsMirrorFallbackWithNoServerBackendIsFatalByName) { @@ -1846,6 +1848,88 @@ TEST(RemoteGuards, CapsMirrorFallbackWithNoServerBackendIsFatalByName) { }); ExpectNamedAbort(child, "Fatal{RoleViolation, \"caps-mirror\"}"); } + +// P5c (gt, CONTRACT-P5C §6 layer 1): the TEXTURE family's object-surface list, in executable +// form - this block IS the pinned list §5.4(b) asks for (the buffer family's five are the +// MGL_BUFFER_GUARD_TEST block above). Every drive shares the buffer family's shape: the texture +// is constructed on the CLIENT thread (its resource_create mint and emission are legal +// client-side), and only the probed method runs on the apply thread, where the guard fires +// BEFORE any level precondition could - the method names below are exactly the ones the guard +// (MipmapStorage.cpp's RefuseLegacyTextureArmFromApplyThread) is hung on: +// +// AllocateStorage, TruncateMipmapLevels, UpdateMipmapSubData, MapMipmapData, +// MarkStorageDirty, MarkStorageDirtyRegion, IsStorageDirty, GetStorageDirtyRegion, +// GetStorageDirtyRects +// +// Deliberately NOT in the list: the shape reads (GetMipmapTexelSize / GetMipmapByteSize / +// GetMipmapLevelCount / GetUploadTargets / GetTarget / IsComplete), which the pinned +// BARRIER-PULLED object-class rows still answer through the unit-object pointer +// (FieldOwnershipTest's list, P3b/P4b/P7) - the per-draw binding walk reads them every draw. +#define MGL_TEXTURE_GUARD_TEST(Name, Id, Call) \ + TEST(RemoteGuards, Name) { \ + const auto child = RunInChild([] { \ + StartControlSession(); \ + MG_State::GLState::TextureObject2D texture(Id); \ + Srv::ServerLoopInstance().RunOnApplyThread( \ + [](void* self) { \ + auto& texture = *static_cast(self); \ + Call; \ + return MOBILEGL_OK; \ + }, \ + &texture); \ + ClientSessionInstance().Stop(); \ + }); \ + ExpectNamedAbort(child, "Fatal{RoleViolation, \"texture-legacy-arm\"}"); \ + } +MGL_TEXTURE_GUARD_TEST(TextureAllocateStorageFromTheApplyThreadIsFatalByName, 711, + texture.AllocateStorage(TextureUploadTarget::Texture2D, 0, + {{4, 4, 1}, 64})) +MGL_TEXTURE_GUARD_TEST(TextureTruncateMipmapLevelsFromTheApplyThreadIsFatalByName, 712, + texture.TruncateMipmapLevels(TextureUploadTarget::Texture2D, 1)) +MGL_TEXTURE_GUARD_TEST(TextureUpdateMipmapSubDataFromTheApplyThreadIsFatalByName, 713, + texture.UpdateMipmapSubData(TextureUploadTarget::Texture2D, 0, {})) +MGL_TEXTURE_GUARD_TEST(TextureMapMipmapDataFromTheApplyThreadIsFatalByName, 714, + (void)texture.MapMipmapData(TextureUploadTarget::Texture2D, 0)) +MGL_TEXTURE_GUARD_TEST(TextureMarkStorageDirtyFromTheApplyThreadIsFatalByName, 715, + texture.MarkStorageDirty(TextureUploadTarget::Texture2D, 0, true)) +MGL_TEXTURE_GUARD_TEST(TextureMarkStorageDirtyRegionFromTheApplyThreadIsFatalByName, 716, + texture.MarkStorageDirtyRegion(TextureUploadTarget::Texture2D, 0, + {0, 0, 0}, {1, 1, 1})) +MGL_TEXTURE_GUARD_TEST(TextureIsStorageDirtyFromTheApplyThreadIsFatalByName, 717, + (void)texture.IsStorageDirty(TextureUploadTarget::Texture2D, 0)) +MGL_TEXTURE_GUARD_TEST(TextureGetStorageDirtyRegionFromTheApplyThreadIsFatalByName, 718, + (void)texture.GetStorageDirtyRegion(TextureUploadTarget::Texture2D, 0)) +MGL_TEXTURE_GUARD_TEST(TextureGetStorageDirtyRectsFromTheApplyThreadIsFatalByName, 719, + (void)texture.GetStorageDirtyRects(TextureUploadTarget::Texture2D, 0, + nullptr, 0)) +#undef MGL_TEXTURE_GUARD_TEST + +// P5c (gt, CONTRACT-P5C §6 layer 2 / audit A1): the client-side half of the gPipeInputs +// single-writer rule. The apply thread is NOT actually inside the applier in this case - the +// flag is raised by hand, which is the exact overlap window the check exists to refuse: a fill +// that ran while a real apply was in flight would race the applier's reads of gPipeInputs. +TEST(RemoteGuards, ClientPipeInputsFillWhileTheApplierOwnsItIsFatalByName) { + const auto child = RunInChild([] { + StartControlSession(); + ClientSession::NoteApplyThreadEnteredApplier(); + MGPipeValidateForVerb(MGPipeVerb::Clear); + ClientSession::NoteApplyThreadLeftApplier(); + ClientSessionInstance().Stop(); + }); + ExpectNamedAbort(child, "Fatal{RoleViolation, \"gPipeInputs\"}"); +} + +// The same fill with the flag down is the legal shape - this is the control that keeps the +// check from being "abort unconditionally". +TEST(RemoteGuards, ClientPipeInputsFillWithTheApplierIdleIsAllowed) { + const auto child = RunInChild([] { + StartControlSession(); + MGPipeValidateForVerb(MGPipeVerb::Clear); + MGPipeLeaveVerb(); + ClientSessionInstance().Stop(); + }); + ExpectChildSuccess(child); +} #endif