diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2929748a..c1d81e2e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -736,6 +736,412 @@ jobs: path: /tmp/core.* if-no-files-found: ignore + # P5's split build. The verify pair cloned, because the problem is the same one: a third + # configuration of the same sources whose whole value depends on the option having taken. + # + # IT CANNOT RIDE ON build-linux'S ARTIFACT. build-linux passes no -DMOBILEGL_PIPE_PUSH, so it is + # a PULL build; MOBILEGL_BUILD_DISAGGREGATED implies MOBILEGL_PIPE_PUSH (the split path decodes + # into the MGPipe applier and PIPE_PUSH is what compiles the applier), so the split arm needs a + # build of its own exactly as the verify arm does. + # + # THE `nm` STEP IS THE POINT OF THIS JOB. In a build without the option, MG_Remote is not + # compiled at all and MOBILEGL_TRANSPORT's parser does not exist - so the variable is accepted by + # the environment and silently ignored (CONTRACT-P5 5). Every downstream lane would then run + # monolith and go green under a name that says split. CMake will not complain about a typo'd -D, + # so the build-level assertion is the only guard, and its absence is precisely what would make + # the whole split arm meaningless. + build-linux-split: + runs-on: ubuntu-latest + timeout-minutes: 120 + permissions: + actions: write + contents: read + env: + BUILD_DIR: build-split + CCACHE_BASEDIR: ${{ github.workspace }} + CCACHE_COMPRESS: "true" + CCACHE_DIR: ${{ github.workspace }}/.ccache + CCACHE_MAXSIZE: 4G + CCACHE_NOHASHDIR: "true" + + steps: + - name: Set Swap Space + uses: pierotofy/set-swap-space@v1.0 + with: + swap-size-gb: 32 + + - name: Checkout repo + uses: actions/checkout@v6 + with: + # recursive, and load-bearing here beyond the usual: MOBILEGL_BUILD_DISAGGREGATED + # SHADOWS ITSELF BACK TO OFF when 3rdparty/flatbuffers/include is missing + # (CMakeLists.txt:471-486, a normal variable rather than a cache force, deliberately). + # A shallow checkout would therefore configure cleanly, build a monolith library, and + # be caught only by the nm step below. + submodules: recursive + + - name: Get CMake + uses: lukka/get-cmake@v4.3.3 + + - name: Restore ccache + uses: actions/cache/restore@v5 + with: + path: .ccache + key: ${{ runner.os }}-test-${{ github.job }}-ccache-v1 + restore-keys: | + ${{ runner.os }}-test-${{ github.job }}-ccache- + + - name: Prepare Vulkan SDK + uses: humbletim/setup-vulkan-sdk@v1.2.1 + with: + vulkan-query-version: 1.4.304.1 + vulkan-components: Vulkan-Headers, Vulkan-Loader + vulkan-use-cache: true + + - name: Update glslang external sources + working-directory: 3rdparty/glslang + run: python update_glslang_sources.py + + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y ccache clang-20 clang++-20 lld-20 libc++-20-dev libc++abi-20-dev libvulkan-dev libegl1-mesa-dev libgles2-mesa-dev libgl1-mesa-dri mesa-vulkan-drivers ninja-build + + - name: Show installed toolchain + run: | + ccache --version + clang-20 --version + clang++-20 --version + dpkg -l 'libc++*' 'libegl*' 'libgles*' 'mesa*' 'vulkan*' || true + + - name: Configure CMake + # Release/INFO like the shipped build, for the same reason build-linux-verify gives. + # INFO specifically matters here: ConfigLoader logs the resolved transport at INFO and + # that line is what run_trace_case.cmake and the integration-split lane read back as + # proof the transport resolved in THIS process. + # + # _INPROC implies _DISAGGREGATED implies _PIPE_PUSH; all three are passed anyway, because + # an implication that is asserted in two places is an implication nobody has to remember. + run: | + cmake -S . -B "${BUILD_DIR}" -G Ninja \ + -DCMAKE_C_COMPILER=clang-20 \ + -DCMAKE_CXX_COMPILER=clang++-20 \ + -DCMAKE_C_COMPILER_LAUNCHER=ccache \ + -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \ + -DCMAKE_BUILD_TYPE=Release \ + -DMOBILEGL_LOG_ACTIVE_LEVEL=MOBILEGL_LOG_LEVEL_INFO \ + -DMOBILEGL_BUILD_TEST=ON \ + -DMOBILEGL_BUILD_BENCHMARK=OFF \ + -DMOBILEGL_BUILD_INTEGRATION_TEST=ON \ + -DMOBILEGL_ITEST_VK_ICD=/usr/share/vulkan/icd.d/lvp_icd.json \ + -DMOBILEGL_BUILD_TRACE_REPLAY=OFF \ + -DMOBILEGL_PIPE_PUSH=ON \ + -DMOBILEGL_BUILD_DISAGGREGATED=ON \ + -DMOBILEGL_BUILD_DISAGGREGATED_INPROC=ON \ + -DCMAKE_POLICY_VERSION_MINIMUM=3.5 + + - name: Build + run: cmake --build "${BUILD_DIR}" --parallel "$(nproc)" + + # `nm` and NOT `nm -D`, for the reason build-linux-verify spells out: everything under + # MG_Remote is a plain namespace symbol in a CXX_VISIBILITY_PRESET=hidden Release build and + # none of it reaches the dynamic table. The symbol count guards the remaining hole - a + # stripped library would make the grep fail for a third, silent reason. + # + # The mirror of this assertion already exists and is the G1 control: monolith-symbol-report + # asserts that a -DMOBILEGL_BUILD_DISAGGREGATED=OFF library defines NO MG_Remote symbol. The + # two together are ARCHITECTURE.md:524's surviving byte-level equality, in both directions. + - name: The split library really carries MG_Remote + run: | + test -f "${BUILD_DIR}/libMobileGL.so" + defined=$(nm --defined-only "${BUILD_DIR}/libMobileGL.so" | wc -l) + if [ "${defined}" -lt 1000 ]; then + echo "::error::nm --defined-only sees only ${defined} symbols in ${BUILD_DIR}/libMobileGL.so - it looks stripped, so the checks below could not have failed honestly" + exit 1 + fi + remote=$(nm --defined-only "${BUILD_DIR}/libMobileGL.so" | grep -c -i "MG_Remote" || true) + if [ "${remote}" -lt 1 ]; then + echo "::error::libMobileGL.so defines no MG_Remote symbol: -DMOBILEGL_BUILD_DISAGGREGATED=ON did not take (a typo'd -D is not a CMake error, and the option shadows itself OFF when 3rdparty/flatbuffers/include is missing). Every lane that consumes this artifact would run MONOLITH while claiming to run split, because the MOBILEGL_TRANSPORT parser does not exist in such a build and the variable is accepted and ignored." + exit 1 + fi + # The transport parser itself, which is the symbol the runtime evidence depends on. + if ! nm --defined-only "${BUILD_DIR}/libMobileGL.so" | grep -q "MGPipeApply"; then + echo "::error::libMobileGL.so defines no MGPipeApply* entry point, so MOBILEGL_PIPE_PUSH did not take either and there is no applier for the split path to decode into" + exit 1 + fi + echo "libMobileGL.so defines ${remote} MG_Remote symbol(s) and the MGPipe applier (${defined} defined symbols)" + + - name: Show ccache stats + if: always() + run: ccache --show-stats + + - name: Release superseded ccache entry + if: github.ref_name == github.event.repository.default_branch + env: + GH_TOKEN: ${{ github.token }} + CACHE_KEY: ${{ runner.os }}-test-${{ github.job }}-ccache-v1 + run: gh cache delete "${CACHE_KEY}" || true + + - name: Save ccache + if: github.ref_name == github.event.repository.default_branch + continue-on-error: true + uses: actions/cache/save@v5 + with: + path: .ccache + key: ${{ runner.os }}-test-${{ github.job }}-ccache-v1 + + - name: Package Linux split runtime + run: | + mkdir -p ci-artifacts + mapfile -t SHARED_LIBS < <(find "${BUILD_DIR}" -type f \( -name '*.so' -o -name '*.so.*' \) -print | sort) + tar \ + --exclude='*/CMakeFiles' \ + --exclude='*.o' \ + --exclude='*.a' \ + --exclude='*.ninja*' \ + --exclude='build.ninja' \ + --exclude='cmake_install.cmake' \ + -czf ci-artifacts/mobilegl-linux-runtime-split.tgz \ + "${BUILD_DIR}/CTestTestfile.cmake" \ + "${BUILD_DIR}/MobileGL/MG_Test" \ + "${BUILD_DIR}/MobileGL/MG_IntegrationTest" \ + "${SHARED_LIBS[@]}" + + - name: Upload Linux split runtime + uses: actions/upload-artifact@v7 + with: + name: mobilegl-linux-runtime-split + path: ci-artifacts/mobilegl-linux-runtime-split.tgz + if-no-files-found: error + + # The split lane itself: the three-arm shape ARCHITECTURE.md:521 asks for verbatim, plus the + # first real run of the five MG_Test/Wire suites, plus P5's two exit-gate negative controls. + integration-split: + runs-on: ubuntu-latest + timeout-minutes: 120 + 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 FIRST REAL RUN OF MG_Test/Wire. Those five suites - Framing, Ring, InProcessTransport, + # ProtocolSmoke and FdPassing - are registered only under MOBILEGL_BUILD_DISAGGREGATED + # (MG_Test/CMakeLists.txt), and before this job existed NO cmake invocation anywhere in this + # workflow passed that option. They had never been compiled by CI, let alone run. + - name: Unit tests on the split runtime + working-directory: build-split + run: ctest --output-on-failure -L unit --no-tests=error -j "$(nproc)" + + # --no-tests=error is half the gate, exactly as in integration-verify: the integration-split + # entries exist only when the library was configured with -DMOBILEGL_BUILD_DISAGGREGATED=ON, + # so a build that lost the option matches nothing and reds here instead of reporting a green + # run of nothing. The other half is the transport-resolution check below. + # + # An entry whose owning package (c1 client, s1 session, v1 server) has not landed SKIPS with + # the reason and never goes green - MG_IntegrationTest/CMakeLists.txt probes MG_Remote for + # c0's signature stubs and disarms the lane while any remain. + - name: Split scenarios under MOBILEGL_TRANSPORT=inproc + working-directory: build-split + env: + MOBILEGL_ITEST_REQUIRE_GPU: "1" + run: | + ulimit -c unlimited + sudo sysctl -w kernel.core_pattern='/tmp/core.%e.%p' + ctest --output-on-failure -L integration-split --no-tests=error -j 4 + + # ARCHITECTURE.md:521 asks for `ctest -L integration-gpu` to be name-for-name identical + # between the monolith and the split arm of the SAME build - the G2 shape extended to a + # third arm. The knob goes in the JOB environment rather than in a ctest property, for the + # reason the MOBILEGL_ESPRYT_DISABLE_INVALIDATE_FLUSH precedent in `integration` gives: a + # property would override it and the arm would not be an arm. + # + # The entries that name MOBILEGL_TRANSPORT in their OWN property (the Split. lanes) keep + # their value in both passes, which is correct: they are the split family in both arms and + # the comparison is about the other 1100. + - name: The same integration entries under monolith and under inproc + working-directory: build-split + env: + MOBILEGL_ITEST_REQUIRE_GPU: "1" + MOBILEGL_MAGMA_FIX_ITERATIONRP_SUBGROUP_SCRATCH: "1" + MOBILEGL_MAGMA_DERIVE_NUM_SUBGROUPS: "1" + MOBILEGL_MAGMA_ITERATIONRP_FIX_BARRIER: "1" + run: | + ulimit -c unlimited + sudo sysctl -w kernel.core_pattern='/tmp/core.%e.%p' + count=$(ctest -N -L integration-gpu | grep -cE '^ *Test *#[0-9]+:') + if [ "${count}" -lt 1 ]; then + echo "::error::the split runtime registers ${count} integration-gpu entries" + exit 1 + fi + echo "integration-gpu entries in the split build: ${count}" + MOBILEGL_TRANSPORT=monolith ctest --output-on-failure -L integration-gpu --no-tests=error -j 4 --output-junit "${RUNNER_TEMP}/arm-monolith.xml" + MOBILEGL_TRANSPORT=inproc ctest --output-on-failure -L integration-gpu --no-tests=error -j 4 --output-junit "${RUNNER_TEMP}/arm-inproc.xml" + # NAME **AND STATUS**, and it is the comparison this step claimed to make and did not + # (review finding N-3): the first version wrote a names file and never read it, and + # `--output-on-failure` treats a SKIPPED test as not-a-failure - so the very failure + # ARCHITECTURE.md:521 is about, "an inproc arm that skipped forty entries the monolith arm + # ran", was invisible here and caught only by the local gate. `ctest -N` cannot see it + # either: this is one build directory, so the two arms have identical name lists by + # construction and the difference is entirely in what each entry DID. + python3 - "${RUNNER_TEMP}/arm-monolith.xml" "${RUNNER_TEMP}/arm-inproc.xml" <<'PY' + import sys, xml.etree.ElementTree as ET + def rows(path): + out = {} + for case in ET.parse(path).getroot().iter('testcase'): + status = 'passed' + if case.find('failure') is not None or case.find('error') is not None: + status = 'failed' + elif case.find('skipped') is not None or case.get('status') in ('notrun', 'disabled'): + status = 'skipped' + out[case.get('name')] = status + return out + a, b = rows(sys.argv[1]), rows(sys.argv[2]) + diff = sorted(set(a) ^ set(b)) + sorted(n for n in set(a) & set(b) if a[n] != b[n]) + if diff: + for name in diff[:40]: + print(f"::error::{name}: monolith={a.get(name, '')} inproc={b.get(name, '')}") + print(f"::error::the monolith and inproc arms of ctest -L integration-gpu differ on " + f"{len(diff)} entries. ARCHITECTURE.md:521 requires them identical name for name " + f"AND status; an entry that SKIPPED on one arm and ran on the other is the " + f"failure this compares for, and it is not a failure to --output-on-failure.") + raise SystemExit(1) + print(f"the two arms agree on all {len(a)} entries, name and status") + PY + + # THE RUNTIME HALF OF "THIS IS REALLY A SPLIT BUILD". The build-level nm check in + # build-linux-split proves the library CARRIES MG_Remote; this proves the transport + # RESOLVED in a process of this lane. ConfigLoader::InitTransport logs one INFO line when it + # selects InProcess, and the DirectGLES.Split.PersistentMapArm. entry is the one Split entry + # with a MOBILEGL_LOG_FILE_PATH of its own (nothing else writes it, so the grep means what it + # says). The line is written during bring-up, before any scenario decides to skip, so this + # check is live from the day the lanes land rather than from the day they stop skipping. + - name: The split lane really resolved the transport + working-directory: build-split + run: | + log=MobileGL/MG_IntegrationTest/persistent-map-arm-split-DirectGLES.log + if [ ! -f "${log}" ]; then + echo "::error::${log} does not exist: the DirectGLES.Split.PersistentMapArm. entry never ran, so nothing in this job establishes that MOBILEGL_TRANSPORT ever resolved to inproc in a live process" + exit 1 + fi + # THE DISTINCTIVE PART OF THE INFO LINE, not the bare KEY=VALUE (review finding M-5): + # ConfigLoader logs `Config: Accepted env variable: MOBILEGL_TRANSPORT=inproc` for the + # env dump too, unconditionally and in a PULL build, and at DEBUG that line is live. + if ! grep -q "MOBILEGL_TRANSPORT=inproc - the MGPipe record stream" "${log}"; then + echo "::error::${log} carries no transport-resolution line. ConfigLoader::InitTransport logs it at INFO when it selects InProcess, and that code exists only in a MOBILEGL_BUILD_DISAGGREGATED build - so this lane ran a monolith library while claiming to be the split lane. (A bare MOBILEGL_TRANSPORT=inproc substring is NOT accepted: the env dump prints one in every build.)" + exit 1 + fi + echo "the split lane resolved MOBILEGL_TRANSPORT=inproc" + + # EXIT GATE E1's NEGATIVE CONTROL and EXIT GATE E3(a)'s, in one step because they have the + # same two-state shape and the same reason for it. + # + # Both controls turn a knob that MUST make a split scenario red: MOBILEGL_IPC_VERB_BARRIER=0 + # removes the lockstep fence R-1 rests on, and MOBILEGL_IPC_PERSISTENT_BLOCK_KB=0 turns the + # persistent-map push off. A control of the verify lane's shape - "this step passes when + # ctest FAILS" - cannot be written yet, because while packages c1/s1/v1 are landing the + # Split entries SKIP and ctest reports green whatever the knob says, so an unconditional + # control would be red for the whole of P5 for a reason that is not a defect. + # + # So the expected state is DERIVED rather than assumed, from the same fact the lanes derive + # it from: MG_IntegrationTest/CMakeLists.txt puts MGITEST_REMOTE_CLIENT_PRESENT=1 into the + # Split entries' ENVIRONMENT exactly when MG_Remote carries no c0 signature stub, and that + # string is in the generated ctest include files this artifact ships. When it is there the + # controls MUST fire; when it is not, the step says so loudly and does not pretend. + - name: Negative controls - the verb barrier and the persistent-map push must be load-bearing + working-directory: build-split + env: + MOBILEGL_ITEST_REQUIRE_GPU: "1" + run: | + # THE ARMED STATE IS DERIVED FROM BEHAVIOUR, not from a marker string in the generated + # ctest files. The first version read MGITEST_REMOTE_CLIENT_PRESENT out of + # *_tests.cmake, which was a restatement of the CMake source probe review finding M-1 + # falsified; the arming condition is now a runtime fact inside each test process, so the + # only honest way to ask it from a shell is to look at what the entries DID. + ctest -L integration-split -j 4 --no-tests=error --output-junit "${RUNNER_TEMP}/isplit.xml" || true + armed=$(python3 - "${RUNNER_TEMP}/isplit.xml" <<'PY' + import sys, xml.etree.ElementTree as ET + ran = 0 + for case in ET.parse(sys.argv[1]).getroot().iter('testcase'): + if case.find('skipped') is None and case.get('status') not in ('notrun', 'disabled'): + ran += 1 + print(ran) + PY + ) + echo "split entries that actually ran: ${armed}" + if [ "${armed}" -lt 1 ]; then + echo "::warning::every DirectGLES.Split. entry SKIPPED, so neither negative control can fire. The arming condition is a runtime fact - MG_Config::Transport, ClientSession::Active() and ImplementedVerbCount(), read by Harness/SplitRuntimePeek - and it becomes true on the commit that lands the last of c1/s1/v1. This step becomes a gate then, with no edit; it is not a green that asserted anything today." + exit 0 + fi + run_control() { + name="$1"; filter="$2"; shift 2 + matched=$(ctest -N -L integration-split -R "${filter}" | grep -cE '^ *Test *#[0-9]+:') + if [ "${matched}" -lt 1 ]; then + echo "::error::${name} selected ${matched} tests; its filter no longer matches anything" + exit 1 + fi + if env "$@" ctest --output-on-failure -L integration-split -R "${filter}" --no-tests=error; then + echo "::error::${name} left ${matched} split entries GREEN, so the knob it turns is not load-bearing and the gate it controls proves nothing." + exit 1 + fi + echo "${name} turned ${matched} selected entries red, as it must" + } + # E1: R-1's lockstep verb barrier. Without it the client keeps pulling fields from a live + # GLContext while the server runs ahead, so the server reads future values. + run_control "negative control E1 (MOBILEGL_IPC_VERB_BARRIER=0)" \ + 'DirectGLES\.Split\.(Triangle|ClearThenReadPixels)' MOBILEGL_IPC_VERB_BARRIER=0 + # E3(a): the persistent-map push. 0 is admitted by ConfigLoader on purpose and is + # documented there as this control. + run_control "negative control E3(a) (MOBILEGL_IPC_PERSISTENT_BLOCK_KB=0)" \ + 'DirectGLES\.Split\.PersistentCoherentMapScenario' MOBILEGL_IPC_PERSISTENT_BLOCK_KB=0 + + - name: Upload split lane logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: integration-split-logs + path: build-split/MobileGL/MG_IntegrationTest/*.log* + if-no-files-found: warn + + - name: Upload core dumps + if: failure() + uses: actions/upload-artifact@v7 + with: + name: integration-split-core-dumps + path: /tmp/core.* + if-no-files-found: ignore + # 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 @@ -1013,6 +1419,7 @@ jobs: matrix: ${{ steps.trace-cases.outputs.matrix }} names: ${{ steps.trace-cases.outputs.names }} verify-matrix: ${{ steps.trace-cases.outputs.verify-matrix }} + split-matrix: ${{ steps.trace-cases.outputs.split-matrix }} steps: - name: Checkout repo uses: actions/checkout@v6 @@ -1025,6 +1432,20 @@ jobs: # The subset the verify build retraces ("verify": true in trace_cases.json). It is a # SUBSET of the matrix above, so retrace-verify needs no fixtures of its own. echo "verify-matrix=$(python3 tools/trace_replay/trace_cases.py --ci --format github-verify-matrix)" >> "$GITHUB_OUTPUT" + # P5's split subset ("split": true, DirectGLES only). Also a SUBSET of the matrix above, + # so retrace-split needs no fixtures of its own either. It is one case today - OpenRA, + # which is what the phase gate names - and trace_cases.py refuses a `split` case that is + # not in CI or does not run DirectGLES, so the subset cannot silently become empty. + SPLIT_MATRIX=$(python3 tools/trace_replay/trace_cases.py --ci --format github-split-matrix) + # AND IT MUST NOT BE EMPTY. An empty `include` is not an error to GitHub - it skips the + # whole retrace-split job with no red anywhere - so the one way this subset can vanish + # silently is guarded here. trace_cases.py now also rejects an unknown manifest key, which + # was the hole: `"splitt": true` loaded clean and emptied the subset (review N-4). + if [ "$(printf '%s' "${SPLIT_MATRIX}" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)["include"]))')" -lt 1 ]; then + echo "::error::the split retrace subset is EMPTY. No case in trace_cases.json carries \"split\": true, so retrace-split would be skipped with no red. Exit gate E2 names OpenRA." + exit 1 + fi + echo "split-matrix=${SPLIT_MATRIX}" >> "$GITHUB_OUTPUT" trace-fixtures: name: trace fixture (${{ matrix.case }}) @@ -1397,6 +1818,214 @@ jobs: build-retrace/tools/trace_replay/${{ matrix.case }}/${{ matrix.backend }}/output/** if-no-files-found: warn + # P5's retrace arm: retrace-verify with MOBILEGL_PIPE_VERIFY=1 swapped for + # MOBILEGL_TRANSPORT=inproc and the comparator's symbol swapped for an MG_Remote one. Exit gate + # E2 is one row - OpenRA at SSIM >= 0.99 - and trace_cases.json's `split: true` is where that + # subset lives. + # + # IT RUNS THE UNCHANGED CTEST NAMES with the transport exported in the JOB environment, rather + # than the SPLIT-suffixed variant entries. build-retrace configures without + # -DMOBILEGL_BUILD_DISAGGREGATED, so the variant entries are deliberately not registered there + # (a name that says SPLIT in a build that cannot be one is worse than no name), and the + # unchanged names are what retrace-verify already proves this shape works with. The variant + # entries exist for a build that DOES configure the option - a local build-split with trace + # replay on - where `ctest -L retrace-split` is self-describing and needs no environment ritual. + # + # WHAT MAKES IT FALSIFIABLE is not the SSIM. A monolith run of OpenRA also scores 1.000: measured + # on this branch, a pull library under MOBILEGL_TRANSPORT=inproc produced ssim=1.0 and was caught + # only by run_trace_case.cmake's transport-resolution assertion. So there are two guards, and the + # SSIM is neither of them: the nm check below (this library carries MG_Remote) and the + # transport-resolution line in the library's own log (this PROCESS resolved inproc). + retrace-split: + name: retrace split (${{ matrix.backend }}, ${{ matrix.case }}) + runs-on: ubuntu-latest + timeout-minutes: 240 + needs: + - build-linux + - build-linux-split + - build-retrace + - trace-cases + - trace-fixtures + # build-linux is needed for the negative control ONLY: its pull library is what the control + # swaps in to prove the transport-resolution assertion is load-bearing. + if: ${{ always() && needs.build-linux.result == 'success' && needs.build-linux-split.result == 'success' && needs.build-retrace.result == 'success' && needs.trace-cases.result == 'success' }} + strategy: + fail-fast: false + max-parallel: 4 + matrix: ${{ fromJSON(needs.trace-cases.outputs.split-matrix) }} + + steps: + - name: Set Swap Space + uses: pierotofy/set-swap-space@v1.0 + with: + swap-size-gb: 16 + + - name: Checkout repo + uses: actions/checkout@v6 + + - name: Download trace fixture + uses: actions/download-artifact@v8 + with: + name: trace-fixture-${{ matrix.case }} + path: trace-fixture-download + + - name: Install trace fixture + run: | + mkdir -p tools/trace_replay/fixtures + find trace-fixture-download -type f -exec cp {} tools/trace_replay/fixtures/ \; + + - 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-mesa-dev libgles2-mesa-dev libgl1-mesa-dri mesa-vulkan-drivers + test -e /usr/lib/x86_64-linux-gnu/libEGL.so + test -e /usr/lib/x86_64-linux-gnu/libGLESv2.so + + - name: Download Linux split runtime + uses: actions/download-artifact@v8 + with: + name: mobilegl-linux-runtime-split + path: . + + - name: Download trace replay + uses: actions/download-artifact@v8 + with: + name: mobilegl-trace-replay + path: . + + # The PULL runtime, for the negative control at the end of this job and for nothing else. + - name: Download Linux pull runtime (negative control) + uses: actions/download-artifact@v8 + with: + name: mobilegl-linux-runtime + path: pull-runtime + + - name: Unpack the pull runtime (negative control) + run: | + tar -xzf pull-runtime/mobilegl-linux-runtime.tgz -C pull-runtime + test -f pull-runtime/build-linux/libMobileGL.so + + - name: Unpack the SPLIT runtime as the library under test + # build-retrace's CTestTestfile.cmake has the absolute path + # /build-linux/libMobileGL.so frozen into every case, so the swap happens here + # rather than through a variable, exactly as in retrace-verify. `nm`, not `nm -D`. + run: | + tar -xzf mobilegl-linux-runtime-split.tgz + tar -xzf mobilegl-trace-replay.tgz + test -f build-split/libMobileGL.so + test -f build-retrace/tools/trace_replay/mobilegl_trace_replay + mkdir -p build-linux + cp build-split/libMobileGL.so build-linux/libMobileGL.so + if ! nm --defined-only build-linux/libMobileGL.so | grep -q -i MG_Remote; then + echo "::error::the library unpacked at build-linux/libMobileGL.so defines no MG_Remote symbol, so this retrace would replay against a monolith build, ignore MOBILEGL_TRANSPORT entirely and match its golden having split nothing" + exit 1 + fi + echo "the library at build-linux/libMobileGL.so is the split build" + + - name: Retrace and validate under MOBILEGL_TRANSPORT=inproc + working-directory: build-retrace/tools/trace_replay + # run_trace_case.cmake turns MOBILEGL_TRANSPORT into assertions of its own - the library + # must have RESOLVED the transport in this process, and its log must carry no Fatal{ - so a + # case that somehow ran the wrong library reds here instead of passing on its golden. That + # log is also the only valid refusal census: the console sink is compiled out of this + # configuration, so a `ctest -V` transcript reports a false zero for Fatal{ lines. + run: | + ulimit -c unlimited + sudo sysctl -w kernel.core_pattern='/tmp/core.%e.%p' + export MOBILEGL_TRANSPORT=inproc + ctest -V --no-tests=error --timeout 10800 \ + -R '^MobileGLTraceReplay\.${{ matrix.case }}\.${{ matrix.backend }}$' + + # THE RETRACE LANE'S OWN ALWAYS-ON NEGATIVE CONTROL, and its absence was review finding M-6: + # this job was a clone of retrace-verify with the one step removed that makes the lane mean + # anything. retrace-verify carries "a corrupted snapshot field must red this retrace" so that + # "79 traces, zero divergences" is not a statement about a comparator nobody watched; the + # same sentence applies here word for word. + # + # The control swaps the PULL library into the frozen path and requires the SAME replay to + # fail. It is the sharpest one available, because OpenRA scores ssim 1.000000 either way - + # measured - so this step fails if and only if run_trace_case.cmake's transport-resolution + # assertion has stopped working, which is the single thing standing between this job and a + # green that ran monolith end to end. + # + # NOT the control BRIEF 7 E2 names ("patch the Clear emitter to drop one emission; SSIM must + # fall below threshold"). That one needs an emitter, i.e. package c1, and it is carried as an + # explicit debt in t1-v1.md rather than silently substituted - which is what the first + # version of this package did. + # + # The rerun replays into the same case directory, so the good run's images are put aside and + # restored whichever way the control goes; "Upload actual image" below runs `if: always()` + # and would otherwise ship the deliberately-wrong run's output under the good run's name. + - name: Negative control - the PULL library must red this split retrace + working-directory: build-retrace/tools/trace_replay + run: | + set +e + GOOD_OUTPUT="${RUNNER_TEMP}/split-verified-output" + rm -rf "${GOOD_OUTPUT}" + if [ -d "${{ matrix.case }}" ]; then cp -a "${{ matrix.case }}" "${GOOD_OUTPUT}"; fi + # The pull library, unpacked from build-linux's artifact, over the frozen path every + # case has baked in. It defines no MG_Remote symbol, so ConfigLoader has no transport + # parser and MOBILEGL_TRANSPORT=inproc is accepted and ignored - the exact shape of "the + # split lane ran monolith". + cp "${GITHUB_WORKSPACE}/pull-runtime/build-linux/libMobileGL.so" \ + "${GITHUB_WORKSPACE}/build-linux/libMobileGL.so" + if nm --defined-only "${GITHUB_WORKSPACE}/build-linux/libMobileGL.so" | grep -q -i MG_Remote; then + echo "::error::the control's own library defines MG_Remote symbols, so it is not a pull build and this control would prove nothing" + exit 1 + fi + export MOBILEGL_TRANSPORT=inproc + ctest -V --no-tests=error --timeout 10800 \ + -R "^MobileGLTraceReplay\.${{ matrix.case }}\.${{ matrix.backend }}$" + control_rc=$? + set -e + if [ -d "${GOOD_OUTPUT}" ]; then + rm -rf "${{ matrix.case }}"; mv "${GOOD_OUTPUT}" "${{ matrix.case }}" + echo "restored the verified run's output over the control's" + fi + if [ "${control_rc}" -eq 0 ]; then + echo "::error::a PULL library passed the split retrace. OpenRA scores ssim 1.000000 under a monolith library too (measured), so the picture is not and cannot be this lane's gate - run_trace_case.cmake's transport-resolution assertion is, and it has stopped working. Every green in this job is then a monolith run under a name that says split." + exit 1 + fi + echo "the pull library turned the split retrace red, as it must (ctest exit ${control_rc})" + + # The refusal census, recorded rather than gated. run_trace_case.cmake already REDS the case + # on any Fatal{, so reaching here means the count is zero - but the number and the distinct + # slot names are what MEASUREMENTS wants from every split run, and reading them out of the + # library's own log is the only way to get them (ctest -V's transcript is a false zero). + - name: Refusal census from the library log + if: always() + working-directory: build-retrace/tools/trace_replay + run: | + log="${{ matrix.case }}/${{ matrix.backend }}/output/mobilegl.log" + if [ ! -f "${log}" ]; then + echo "no ${log} - the replay wrote no library log" + exit 0 + fi + echo "Fatal{ lines: $(grep -c 'Fatal{' "${log}" || true)" + grep -o 'Fatal{[A-Za-z]*, "[^"]*"' "${log}" | sort | uniq -c | sort -rn | head -20 || true + grep -m1 "MOBILEGL_TRANSPORT=" "${log}" || echo "no transport line in ${log}" + + - name: Upload core dumps + if: failure() + uses: actions/upload-artifact@v7 + with: + name: retrace-split-core-dumps-${{ matrix.backend }}-${{ matrix.case }} + path: /tmp/core.* + if-no-files-found: ignore + + - name: Upload actual image + if: always() + uses: actions/upload-artifact@v7 + with: + name: retrace-split-result-${{ matrix.backend }}-${{ matrix.case }} + path: | + build-retrace/tools/trace_replay/${{ matrix.case }}/actual-images/** + build-retrace/tools/trace_replay/${{ matrix.case }}/${{ matrix.backend }}/output/** + if-no-files-found: warn + # G1's own job: the pull build must be the tree before P1, symbol for symbol and byte for byte. # workflow_dispatch only - it builds the library twice from scratch, and its answer is about a # BASELINE rather than about this push, so a per-push run would be measuring the wrong pair. diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 8597d0b6..4ebf41d2 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -56,6 +56,8 @@ add_executable(MobileGLIntegrationTest Harness/PipeApplyPeek.cpp Harness/P4aSeamPeek.cpp Harness/P4aFinalFixPeek.cpp + Harness/PersistentMapPeek.cpp + Harness/SplitRuntimePeek.cpp Scenarios/OrientationScenario.cpp Scenarios/CrossFrameBufferScenario.cpp Scenarios/ResidentIndexScenario.cpp @@ -141,6 +143,11 @@ add_executable(MobileGLIntegrationTest Scenarios/ObjectSubsystemControlScenario.cpp Scenarios/P4aSeamAuditScenario.cpp Scenarios/P4aFinalFixScenario.cpp + # P5's two new scenarios, targets B and C of the reduced path (BRIEF-P5 4). Both are + # ORDINARY GL scenarios that run in every lane; the DirectGLES.Split. entries further down + # run the same cases with MOBILEGL_TRANSPORT=inproc. + Scenarios/TriangleScenario.cpp + Scenarios/PersistentCoherentMapScenario.cpp ) target_include_directories(MobileGLIntegrationTest PRIVATE @@ -1757,3 +1764,232 @@ if (MOBILEGL_PIPE_VERIFY) ENVIRONMENT "${MGL_ITEST_VULKAN_POISON_OMIT_ENVIRONMENT}" ) endif() + +# --- P5: the DirectGLES.Split. lanes and the persistent-map ARM lanes ------------------------ +# +# THE PRIVATE SECOND LABEL IS WHAT MAKES THE LANE FALSIFIABLE, and it is the verify block above +# copied verbatim for the same argument: `ctest -L integration-split --no-tests=error` in a build +# that forgot -DMOBILEGL_BUILD_DISAGGREGATED=ON matches NOTHING and FAILS, instead of reporting a +# green run of zero entries. Keeping `integration-gpu` as well is why "a split build's +# `ctest -L integration-gpu` still describes the whole registration set" stays true. +# +# REGISTERED INSIDE `if (MOBILEGL_BUILD_DISAGGREGATED)`, and that is what keeps gate G2 (pull and +# push name-for-name identical) untouched: neither of those two builds sets the option, so the +# Split family is absent from BOTH of them rather than present in one. The two SCENARIO FILES the +# lanes point at are added to the source list unconditionally, so their cases DO appear in the +# ambient DirectGLES./DirectVulkan. registrations of every build - they are ordinary GL scenarios +# and their monolith behaviour is the baseline the split arm is compared against. +# +# WHAT ARMS THE SPLIT LANES, AND WHY IT IS NOT A PROBE OVER SOURCE TEXT. +# +# Until a real client session exists, MOBILEGL_TRANSPORT=inproc is parsed (CONTRACT-P5 5) and then +# nothing consumes it, so a Split entry would go green having run monolith end to end - the exact +# failure the lane exists to prevent. The first version of this file answered "has the client +# landed" with a CMake conjunction over c0's stub files: the c1 symbols present AND no +# `Fatal.Unimplemented` surviving. REVIEW FINDING M-1 FALSIFIED IT BY PERFORMING IT - a +# `sed -i 's/Fatal{Unimplemented/Fatal{NotYetImplemented/'` over c0's six stubs, with every entry +# point still aborting and ImplementedVerbCount() still 0, armed all eleven lanes and EIGHT WENT +# GREEN. A single comment line carrying the marker did the opposite and would keep them dark +# forever (M-2), and two stub files outside the two probed directories allowed a partial arm (M-3). +# +# A statement about source text can always be falsified by editing source text, and the people +# most likely to edit it are the ones landing the packages the probe watches for. So the arming +# condition moved into the PROCESS: Harness/SplitRuntimePeek.cpp reads MG_Config::Transport, +# ClientSession::Active() and ImplementedVerbCount() - three values c0 shipped and documented, +# none of which a message edit can move - and every Split case skips, naming the first one that is +# not true. All this file has to decide now is whether that peek can be COMPILED, which is exactly +# "did this build compile MG_Remote", which is exactly the build option. +if (MOBILEGL_BUILD_DISAGGREGATED) + target_compile_definitions(MobileGLIntegrationTest PRIVATE -DMGITEST_SPLIT_RUNTIME_PEEK=1) + message(STATUS "Integration tests: the DirectGLES.Split. lanes arm from the RUNNING PROCESS - " + "MG_Config::Transport, ClientSession::Active() and ImplementedVerbCount() - and " + "skip naming the first of those that is not true") +endif() + +# Q-3, made a build fact rather than documentation. ConfigLoader logs the resolved transport at +# INFO, and that line is what run_trace_case.cmake reads back as proof a retrace really went +# split. At WARN or above it is compiled out and every split retrace reds for a reason that is not +# a defect; at DEBUG, ConfigLoader's unconditional env dump prints a confusable KEY=VALUE line in +# a PULL build too (review M-5, also fixed on the reading side). The integration lanes themselves +# no longer depend on the log level at all - they read the variable - so this is a WARNING. +if (MOBILEGL_BUILD_DISAGGREGATED AND DEFINED MOBILEGL_LOG_ACTIVE_LEVEL + AND NOT MOBILEGL_LOG_ACTIVE_LEVEL STREQUAL "MOBILEGL_LOG_LEVEL_INFO" + AND NOT MOBILEGL_LOG_ACTIVE_LEVEL STREQUAL "MOBILEGL_LOG_LEVEL_DEBUG") + message(WARNING "Integration tests: MOBILEGL_LOG_ACTIVE_LEVEL=${MOBILEGL_LOG_ACTIVE_LEVEL} in a " + "disaggregated build. ConfigLoader's transport line is MGLOG_I, so the " + "trace-replay split arm's transport-resolution assertion cannot see it and every " + "split retrace will red. Use MOBILEGL_LOG_LEVEL_INFO.") +endif() + +# Package b1's client-side persistent-map tracker, which is what Harness/PersistentMapPeek.cpp +# asks the membership question of (b1-v1.md 4.1 item 2). TWO halves, and both are needed: +# MOBILEGL_BUILD_DISAGGREGATED because a build that never compiled MG_Remote cannot LINK the call, +# and the content probe because b1 may not have landed it yet. __has_include in the peek would +# answer yes in a pull build - the header is in the source tree of every build - and turn a +# healthy pull build into a link error, which is why this decision is made here. +if (MOBILEGL_BUILD_DISAGGREGATED) + mgl_itest_probe_for_symbol(MGL_ITEST_PERSISTENT_MAP_TRACKER + "${MGL_ITEST_ROOT}/MobileGL/MG_Remote/Client" "IsLivePersistentMap") + if (MGL_ITEST_PERSISTENT_MAP_TRACKER) + message(STATUS "Integration tests: package b1's persistent-map tracker is present " + "(${MGL_ITEST_PERSISTENT_MAP_TRACKER}) - PersistentCoherentMapScenario's " + "membership assertion is live") + target_compile_definitions(MobileGLIntegrationTest PRIVATE -DMGITEST_PERSISTENT_MAP_TRACKER=1) + else() + message(STATUS "Integration tests: no source under MobileGL/MG_Remote/Client names " + "IsLivePersistentMap - package b1's tracker has not landed, so " + "PersistentCoherentMapScenario's membership assertion SKIPS") + endif() +endif() + +# ARCHITECTURE.md:543 - every new ctest ENVIRONMENT and add_trace_replay_test's SPLIT branch +# carries MOBILEGL_IPC_SERVER_PATH, because the dladdr fallback cannot find the server from a +# binary that links MobileGL_s statically. P5 is inproc-only and nothing reads the value yet; it +# is carried now so that an unparsed variable and a parsed-and-ignored one stop being +# indistinguishable the day P6 consumes it. A generator expression is deliberately NOT used here: +# a $ inside a gtest_discover_tests PROPERTIES value is written into the +# generated ctest include file verbatim and never expanded. +set(MGL_ITEST_SPLIT_SERVER_PATH "${CMAKE_BINARY_DIR}/libMobileGLServer.so") + +mgl_itest_join_environment(MGL_ITEST_GLES_SPLIT_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_TRANSPORT=inproc" + "MOBILEGL_IPC_SERVER_PATH=${MGL_ITEST_SPLIT_SERVER_PATH}" "MGITEST_SPLIT_LANE=1" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) + +# The two counting lanes, one per transport, and they are a PAIR: exit gate E3(c) asks that the +# split arm's `mpr` equal the monolith arm's, and one test process can only ever see its own. So +# both lanes run the same workload and assert the same constant, and the equality holds by +# construction with each half able to fail on its own. +# +# Each gets a LOG PATH OF ITS OWN and a TEST_FILTER that selects ONE case, for the reason +# PipeStatsWindow.h gives: the library opens its log fopen(path, "w"), so every process in a lane +# truncates it, and two readers in one lane race under `ctest -j`. RESOURCE_LOCK on top of that, +# for the reason the UnlocatedIoBlocks lane gives - measured on this tree, three runs of the full +# integration-gpu label at -j 8 produced 4, 0 and 2 spurious failures of the log-reading cases +# without it. +# +# The monolith lane declares NO arm: which arm AcquireMemoryRange takes for a sub-16-MiB +# PERSISTENT|WRITE|COHERENT map is a property of the driver and the build, so the case RECORDS it +# there and skips the assertion. Only the split lane - where R-6 pins the adopt tier at T2 - +# declares one. +# MOBILEGL_TRANSPORT=monolith IS NAMED HERE ON PURPOSE, and it is review finding N-6. This entry +# is the MONOLITH half of a pair; without the property a job-level or gate-level +# `MOBILEGL_TRANSPORT=inproc` export reaches it (observed: its own log then carried +# `Config: MOBILEGL_TRANSPORT=inproc`) and, in the inproc arm of the three-arm A/B, BOTH halves of +# the pair were inproc. Nothing failed - each half asserts the same constant - but the pair was +# not the pair its name describes. A ctest ENVIRONMENT property overrides the job environment, +# which is the one case where that is what you want. +mgl_itest_join_environment(MGL_ITEST_GLES_PMAP_ARM_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_PMAP_LANE=1" "MOBILEGL_TRANSPORT=monolith" + "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/persistent-map-arm-DirectGLES.log" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) + +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.PersistentMapArm." + TEST_FILTER "PersistentCoherentMapScenario.TheMapLandsInTheArmItsLaneDeclares" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + RESOURCE_LOCK persistent-map-arm-DirectGLES.log + ENVIRONMENT "${MGL_ITEST_GLES_PMAP_ARM_ENVIRONMENT}" +) + +if (MOBILEGL_BUILD_DISAGGREGATED) + # One block per scenario, following the DirectGLES.MapPersistentRoundtrips. precedent: a + # `:`-separated multi-pattern TEST_FILTER is not used anywhere in this file, so its escaping + # through gtest_discover_tests' flat PROPERTIES forwarding is unproven, and three blocks cost + # nothing. + # + # ClearThenReadPixelsScenario is target A of the reduced path and already existed; Triangle is + # target B and PersistentCoherentMap is target C, both new in P5. + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.Split." + TEST_FILTER "ClearThenReadPixelsScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-split" + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_SPLIT_ENVIRONMENT}" + ) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.Split." + TEST_FILTER "TriangleScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-split" + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_SPLIT_ENVIRONMENT}" + ) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.Split." + TEST_FILTER "PersistentCoherentMapScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-split" + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_SPLIT_ENVIRONMENT}" + ) + + # The split half of the counting pair. MGITEST_PERSISTENT_MAP_ARM=emulated is R-6: under split + # the adopt tier is pinned at T2, the resource owner declines every acquisition and the client + # pushes the mapping's dirty blocks - so pmap must be non-zero and mpr must be the monolith + # lane's number. pmap == 0 here means MGPipeApplyMapPersistent handed back a pointer, which is + # exit gate E3(d) failing, and under inproc that failure is invisible in pixels. + mgl_itest_join_environment(MGL_ITEST_GLES_SPLIT_PMAP_ARM_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_TRANSPORT=inproc" + "MOBILEGL_IPC_SERVER_PATH=${MGL_ITEST_SPLIT_SERVER_PATH}" "MGITEST_SPLIT_LANE=1" + "MGITEST_PMAP_LANE=1" "MGITEST_PERSISTENT_MAP_ARM=emulated" + "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/persistent-map-arm-split-DirectGLES.log" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) + + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.Split.PersistentMapArm." + TEST_FILTER "PersistentCoherentMapScenario.TheMapLandsInTheArmItsLaneDeclares" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-split" + TIMEOUT ${MGL_ITEST_TIMEOUT} + RESOURCE_LOCK persistent-map-arm-split-DirectGLES.log + ENVIRONMENT "${MGL_ITEST_GLES_SPLIT_PMAP_ARM_ENVIRONMENT}" + ) + + # --- EXIT GATE E3(e): the same checks with a ring small enough to force back-pressure ----- + # + # BRIEF 7 E3(e) - "以上五条再跑一遍 ring 小到足以至少发生一次背压等待的配置" - was missing from + # the first cut of this package and missing from its report (review finding M-7). It is a + # LANE, not a new scenario: the same three split scenarios with SEG_CMD and SEG_STAGE at their + # floor, so a workload that fits comfortably in the 8 MiB / 32 MiB defaults has to wrap and + # wait at least once. + # + # 1 MiB is ConfigLoader's minimum for both (Config.h / ConfigLoader.cpp:363-364, floors of 1 + # rather than 0 because a ring caps ONE record at half its size). That caps a record at 512 + # KiB, which every record on the reduced path is far under, so the ring is legal and small + # rather than unusable. + # + # WHAT THIS LANE DOES NOT YET ASSERT, stated rather than implied: that a back-pressure wait + # ACTUALLY happened. That needs a counter only package s1 can publish (a producer-parked or + # ring-full tally on RingControl); until it exists this lane proves the five checks survive a + # small ring, not that the small ring bit. The gap is named in t1-v1.md's debts rather than + # left for someone to discover from a green. + mgl_itest_join_environment(MGL_ITEST_GLES_SPLIT_SMALL_RING_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_TRANSPORT=inproc" + "MOBILEGL_IPC_SERVER_PATH=${MGL_ITEST_SPLIT_SERVER_PATH}" "MGITEST_SPLIT_LANE=1" + "MGITEST_SMALL_RING_LANE=1" "MOBILEGL_IPC_RING_MB=1" "MOBILEGL_IPC_STAGE_MB=1" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) + + foreach(mglItestSmallRingScenario ClearThenReadPixelsScenario TriangleScenario + PersistentCoherentMapScenario) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.Split.SmallRing." + TEST_FILTER "${mglItestSmallRingScenario}.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-split" + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_SPLIT_SMALL_RING_ENVIRONMENT}" + ) + endforeach() +endif() diff --git a/MobileGL/MG_IntegrationTest/Harness/PersistentMapPeek.cpp b/MobileGL/MG_IntegrationTest/Harness/PersistentMapPeek.cpp new file mode 100644 index 00000000..6c957e26 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/PersistentMapPeek.cpp @@ -0,0 +1,91 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Harness/PersistentMapPeek.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 "PersistentMapPeek.h" + +#if !defined(__ANDROID__) +#include +#include +#define MGITEST_PERSISTENT_MAP_PEEK_LIVE 1 +#endif + +// MGITEST_PERSISTENT_MAP_TRACKER is defined by MG_IntegrationTest/CMakeLists.txt, and only when +// BOTH halves are true: the build compiled MG_Remote (so the symbol can link) AND some source +// under MG_Remote/Client names IsLivePersistentMap (so package b1 landed it). __has_include is +// NOT enough on its own - the header exists in the source tree of every build, including the pull +// build that never compiles MG_Remote, so keying on it would turn a healthy pull build into a +// link error. +#if defined(MGITEST_PERSISTENT_MAP_PEEK_LIVE) && defined(MGITEST_PERSISTENT_MAP_TRACKER) +#include +#define MGITEST_PERSISTENT_MAP_TRACKER_LIVE 1 +#endif + +namespace MGITest { + + bool PersistentMapPeekAvailable() { +#if defined(MGITEST_PERSISTENT_MAP_PEEK_LIVE) + return true; +#else + return false; +#endif + } + + bool PersistentMapTrackerAvailable() { +#if defined(MGITEST_PERSISTENT_MAP_TRACKER_LIVE) + return true; +#else + return false; +#endif + } + +#if defined(MGITEST_PERSISTENT_MAP_PEEK_LIVE) + namespace { + // The frontend object behind a GL buffer name, or null. GetBufferObject mints on demand + // for a name that was generated and never bound, which is harmless here: a scenario only + // ever asks about a buffer it has already defined and mapped, and a null store answers + // "not adopted", which is the same answer an un-mapped buffer would give. + MobileGL::MG_State::GLState::BufferObject* FrontendBuffer(unsigned int bufferName) { + if (bufferName == 0) return nullptr; + auto& context = MobileGL::MG_State::pGLContext; + if (!context) return nullptr; + const auto& buffer = context->GetBufferObject(static_cast(bufferName)); + return buffer.get(); + } + } // namespace +#endif + + bool PeekBufferIsAdoptedPersistentMap(unsigned int bufferName, bool* outAdopted) { +#if defined(MGITEST_PERSISTENT_MAP_PEEK_LIVE) + if (outAdopted == nullptr) return false; + MobileGL::MG_State::GLState::BufferObject* buffer = FrontendBuffer(bufferName); + if (buffer == nullptr) return false; + *outAdopted = static_cast(buffer->IsBackendPersistentMapped()); + return true; +#else + (void)bufferName; + (void)outAdopted; + return false; +#endif + } + + bool PeekBufferIsLivePersistentMap(unsigned int bufferName, bool* outLive) { +#if defined(MGITEST_PERSISTENT_MAP_TRACKER_LIVE) + if (outLive == nullptr) return false; + MobileGL::MG_State::GLState::BufferObject* buffer = FrontendBuffer(bufferName); + if (buffer == nullptr) return false; + *outLive = static_cast( + MobileGL::MG_Remote::Client::PersistentMapTracker::IsLivePersistentMap(*buffer)); + return true; +#else + (void)bufferName; + (void)outLive; + return false; +#endif + } + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Harness/PersistentMapPeek.h b/MobileGL/MG_IntegrationTest/Harness/PersistentMapPeek.h new file mode 100644 index 00000000..374401a0 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/PersistentMapPeek.h @@ -0,0 +1,61 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Harness/PersistentMapPeek.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 +// +// WHICH ARM A PERSISTENT|WRITE|COHERENT MAP LANDED IN, read from a scenario. +// +// It exists for exit gate E3's first assertion, and package b1 (b1-v1.md 4.1) names the +// spelling: `IsBackendPersistentMapped()` must be FALSE, i.e. the store was NOT adopted and the +// CPU shadow is still the source of truth, i.e. the emulated arm. The question has no answer in +// the GL API at all - both arms map, both arms take the application's writes, both arms draw the +// same pixels - so a scenario with no peek is a scenario that silently tests whichever arm the +// driver and the build happened to choose. `MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION` does not +// separate them either: it is read only inside TryAdoptLargeStorage, and a scenario-sized buffer +// never reaches the 16 MiB threshold that calls it. +// +// A separate translation unit for BackendCapsPeek.h's reason, verbatim: the scenario sources +// include the GL headers with prototypes and MobileGL's umbrella header is not meant to meet them +// in one file. +// +// Every entry point returns FALSE, touching nothing, where the state is out of reach - on Android +// this module links the shipping libMobileGL.so built -fvisibility=hidden, so no internal symbol +// resolves, and the tracker half additionally needs a build that compiled MG_Remote/Client. A +// caller that gets false must SKIP rather than pass: "could not look" is not "it was emulated". + +#pragma once + +namespace MGITest { + + // True when the peek can answer at all in this build. A scenario asks this first so that its + // skip message can name WHY it could not look. + bool PersistentMapPeekAvailable(); + + // BufferObject::IsBackendPersistentMapped() for the buffer with this GL name. + // + // returns false -> could not look (no peek in this build, no current context, or no such + // buffer). *outAdopted is untouched. + // returns true -> *outAdopted is true on the ADOPTED arm (the resource owner minted + // host-visible coherent storage and the shadow was released) and false on + // the EMULATED arm (the owner declined; the shadow is the truth and the + // client has to push blocks). R-6 pins the split arm at emulated. + bool PeekBufferIsAdoptedPersistentMap(unsigned int bufferName, bool* outAdopted); + + // True when this build compiled package b1's client-side persistent-map tracker, so the + // membership predicate below means something. CMake answers it, by probing MG_Remote/Client + // for the symbol: a build that never compiled MG_Remote cannot link the call, so the decision + // has to be made before the compiler sees it rather than by __has_include. + bool PersistentMapTrackerAvailable(); + + // MG_Remote::Client::PersistentMapTracker::IsLivePersistentMap() for this buffer - b1-v1.md + // 4.1 item 2. The membership set is meant to be exactly the early-out chain of + // SyncPersistentMappedRange; asking it here is what makes a drift between the two fail in a + // named test rather than silently stop the push. + // + // Same contract as above: false means "could not look". + bool PeekBufferIsLivePersistentMap(unsigned int bufferName, bool* outLive); + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Harness/PipeStatsWindow.h b/MobileGL/MG_IntegrationTest/Harness/PipeStatsWindow.h index 8f44a39c..02e86746 100644 --- a/MobileGL/MG_IntegrationTest/Harness/PipeStatsWindow.h +++ b/MobileGL/MG_IntegrationTest/Harness/PipeStatsWindow.h @@ -91,4 +91,23 @@ namespace MGITest::PipeStatsWindow { return -1; } + // The same lookup for a counter that is printed as a FIXED-POINT PER-FRAME FIGURE rather + // than as an integer, which is every member of the bytes/f[...] bracket: FormatWindowLine + // divides each byte class by the window's frame count and prints two decimals whenever the + // window contains a Present. `pmap` is one of those, so CounterOrAbsent's strtoll reads + // "0.37" as 0 and an assertion that a push HAPPENED silently becomes an assertion that it + // pushed at least one whole byte per frame - the one way this counter can be wrong without + // ever failing. Returns -1.0 when the line does not carry the name; every real value of a + // byte class is >= 0, so the sentinel cannot collide with one. + inline double CounterAsDoubleOrAbsent(const Window& window, const char* shortName) { + if (!window.found) return -1.0; + for (const char* prefix : {" ", "["}) { + const std::string key = std::string(prefix) + shortName + "="; + const std::size_t at = window.line.find(key); + if (at == std::string::npos) continue; + return std::strtod(window.line.c_str() + at + key.size(), nullptr); + } + return -1.0; + } + } // namespace MGITest::PipeStatsWindow diff --git a/MobileGL/MG_IntegrationTest/Harness/ScenarioFixture.h b/MobileGL/MG_IntegrationTest/Harness/ScenarioFixture.h index 25a71d1d..e3055c04 100644 --- a/MobileGL/MG_IntegrationTest/Harness/ScenarioFixture.h +++ b/MobileGL/MG_IntegrationTest/Harness/ScenarioFixture.h @@ -28,6 +28,7 @@ #include #include "HeadlessGL.h" +#include "SplitLane.h" namespace MGITest { @@ -66,6 +67,33 @@ namespace MGITest { class ScenarioTest : public ::testing::Test { protected: + // THE BEHAVIOURAL HALF OF THE SPLIT LANE'S CLAIM, and it is in the DESTRUCTOR rather than + // in TearDown() on purpose: gtest calls only the MOST DERIVED TearDown, and every scenario + // that overrides it would have to remember to chain here. The fixture destructor always + // runs, and it runs before the test result is finalized, so ADD_FAILURE() is recorded. + // + // What it asserts: a case that ran in an ARMED split lane must have moved the client + // encoder's record ordinal. Everything else in the lane - the pixels, the readbacks, the + // arm assertion - is equally true of a monolith run of the same workload; this is the one + // statement that is only true if records crossed the ring. An emit table that resolves the + // transport and then falls through to the driver passes every other assertion in the file + // and fails exactly here. + ~ScenarioTest() override { + if (!m_splitAssertionsArmed) return; + if (IsSkipped() || HasFailure()) return; + const SplitRuntimeState after = PeekSplitRuntime(); + if (after.emitSeq <= m_emitSeqAtSetUp) { + ADD_FAILURE() << "this case ran in an armed DirectGLES.Split. lane and the client " + "encoder's record ordinal did not move: EmitSeq was " + << m_emitSeqAtSetUp << " at SetUp and is " << after.emitSeq + << " now. The workload drew, cleared and read pixels, so records were " + "due - a sequence that did not advance means the emit table " + "resolved the transport and then did not put anything on the wire, " + "which every other assertion in this lane is blind to because a " + "monolith run of the same workload produces the same pixels."; + } + } + void SetUp() override { m_ready = false; HeadlessGL& gl = HeadlessGL::Get(); @@ -95,6 +123,36 @@ namespace MGITest { FAIL() << "MOBILEGL_ITEST_REQUIRE_HARDWARE_GPU is set but the context landed on a software " << "rasterizer: " << gl.RendererString(); } + // P5's DirectGLES.Split. lanes, in ONE place rather than in each scenario they point + // at - the Split family also points at ClearThenReadPixelsScenario, which is target A + // of the reduced path and predates P5, and any later Split lane gets the same + // guarantee without anyone having to remember it. + // + // THE ARMING QUESTION IS ASKED OF THE PROCESS, not of the source tree. Until a real + // client session exists, MOBILEGL_TRANSPORT=inproc is parsed and then nothing consumes + // it, so every case in the lane would go GREEN against the monolith path under a name + // that says it tested the split one. The first version of this guard answered the + // question with a CMake grep over c0's stub files, and review finding M-1 falsified it + // by renaming one string: eleven lanes armed and eight went green. Harness/ + // SplitRuntimePeek.h now answers it from MG_Config::Transport, ClientSession::Active() + // and ImplementedVerbCount(), none of which a message edit can move. Registrations are + // never deleted (gate G14); they skip, naming exactly which fact is not true. + if (SplitLane::IsSplitLane()) { + if (const std::string splitSkip = SplitLane::SkipReasonForSplitOnlyAssertions(); + !splitSkip.empty()) { + GTEST_SKIP() << splitSkip; + } + // Armed. Take the wire's baseline, so the destructor can require that this case + // actually PUT SOMETHING THROUGH IT (review finding N-5: ten of the eleven Split + // entries had no runtime evidence of anything, and their green meant only "the + // same GL workload passed"). + const SplitRuntimeState state = PeekSplitRuntime(); + m_splitAssertionsArmed = true; + m_emitSeqAtSetUp = state.emitSeq; + RecordProperty("split_transport", state.transportName); + RecordProperty("split_implemented_verbs", static_cast(state.implementedVerbs)); + RecordProperty("split_emit_seq_at_setup", static_cast(state.emitSeq)); + } // A scenario starts from a clean slate but shares the context (and so // the renderer's memos) with every other scenario in this process - // which is exactly the situation both shipped bugs needed. @@ -122,6 +180,8 @@ namespace MGITest { } bool m_ready = false; + bool m_splitAssertionsArmed = false; + unsigned long long m_emitSeqAtSetUp = 0; }; } // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Harness/SplitLane.h b/MobileGL/MG_IntegrationTest/Harness/SplitLane.h new file mode 100644 index 00000000..35b7df60 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/SplitLane.h @@ -0,0 +1,84 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Harness/SplitLane.h +// Copyright (c) 2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// The harness markers the `DirectGLES.Split.` ctest entries set. +// +// WHAT IS AND IS NOT DECIDED HERE. These markers say what the LANE asked for. Whether the lane +// GOT it is a different question and it is answered by Harness/SplitRuntimePeek.h, out of the +// running process - see the long argument in that header. The split of responsibility matters: +// an environment variable is a request, and this package's first version treated a request (plus +// a grep over source text) as evidence that the request had been honoured. Review finding M-1 +// falsified that by renaming one string in six files, which armed eleven lanes and turned eight +// of them green against the monolith path. +// +// MGITEST_SPLIT_LANE=1 +// Set by the DirectGLES.Split.* entries and by nothing else. It is how a case in ONE binary, +// registered many times over, knows which registration it is running under. It is NOT +// evidence of anything about the transport. +// +// MGITEST_PERSISTENT_MAP_ARM=adopted|emulated +// The arm the LANE declares. AcquireMemoryRange adopts a PERSISTENT|WRITE map that is not +// FLUSH_EXPLICIT whenever the resource owner mints one (BufferObject.cpp:645-661), and +// declines to the shadow otherwise - two completely different code paths, chosen by the +// driver and the build rather than by the test, and MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION +// does NOT separate them (it guards TryAdoptLargeStorage's 16 MiB path, which a +// scenario-sized buffer never reaches at all). So the lane states which arm it expects and +// PersistentCoherentMapScenario asserts it landed there, through +// Harness/PersistentMapPeek.h's read of IsBackendPersistentMapped(). R-6 pins the split lane +// at T2 = declined = emulated. +// +// MGITEST_PMAP_LANE=1 +// The one counting entry per transport that reads the library's summary line back. It has a +// MOBILEGL_LOG_FILE_PATH of its own and a RESOURCE_LOCK on it. +// +// MGITEST_SMALL_RING_LANE=1 +// Exit gate E3(e)'s lane: the same split scenarios with MOBILEGL_IPC_RING_MB and +// MOBILEGL_IPC_STAGE_MB at their floor, so that the ring is small enough to make at least one +// back-pressure wait happen. A case uses it only to say so in its recorded properties; the +// ring sizes themselves reach the library through MOBILEGL_IPC_*. + +#pragma once + +#include +#include + +#include "SplitRuntimePeek.h" + +namespace MGITest::SplitLane { + + inline std::string MarkerValue(const char* name) { + const char* value = std::getenv(name); + return (value != nullptr) ? std::string(value) : std::string(); + } + + inline bool MarkerIsOne(const char* name) { return MarkerValue(name) == "1"; } + + // True in the DirectGLES.Split.* entries only. + inline bool IsSplitLane() { return MarkerIsOne("MGITEST_SPLIT_LANE"); } + + // True in exit gate E3(e)'s small-ring lane. + inline bool IsSmallRingLane() { return MarkerIsOne("MGITEST_SMALL_RING_LANE"); } + + // Empty when this case may assert; otherwise the reason to GTEST_SKIP() with. The reason is + // spelled out rather than summarised because a skip line is the only thing anyone reads when + // they ask "did the split lane actually run" - and because the previous version of this + // message named the wrong missing thing (review finding N-1): it said MG_Remote/Client did + // not exist, on a tree where it existed and compiled and every entry point aborted. + inline std::string SkipReasonForSplitOnlyAssertions() { + if (!IsSplitLane()) { + return "not the split lane (MGITEST_SPLIT_LANE is unset): this case's split-only " + "assertions are about a live MG_Remote client session and say nothing in a " + "monolith process"; + } + return SplitRuntimeSkipReason(); + } + + // "adopted", "emulated", or empty when the lane declared nothing. + inline std::string DeclaredPersistentMapArm() { return MarkerValue("MGITEST_PERSISTENT_MAP_ARM"); } + +} // namespace MGITest::SplitLane diff --git a/MobileGL/MG_IntegrationTest/Harness/SplitRuntimePeek.cpp b/MobileGL/MG_IntegrationTest/Harness/SplitRuntimePeek.cpp new file mode 100644 index 00000000..87c936c5 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/SplitRuntimePeek.cpp @@ -0,0 +1,88 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Harness/SplitRuntimePeek.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 "SplitRuntimePeek.h" + +// MGITEST_SPLIT_RUNTIME_PEEK is defined by MG_IntegrationTest/CMakeLists.txt under +// MOBILEGL_BUILD_DISAGGREGATED and nowhere else. NO SOURCE PROBE decides it: the three symbols +// below are c0's, they exist in every disaggregated build from the contract commit onward, and +// their VALUES are what answer the question. That is the whole of the fix for review findings +// M-1, M-2 and M-3 - there is no longer a string for anyone to rename, comment out, or land +// outside a probed directory. +#if defined(MGITEST_SPLIT_RUNTIME_PEEK) && !defined(__ANDROID__) +#include + +#include +#include +#define MGITEST_SPLIT_RUNTIME_PEEK_LIVE 1 +#endif + +namespace MGITest { + + SplitRuntimeState PeekSplitRuntime() { + SplitRuntimeState state; +#if defined(MGITEST_SPLIT_RUNTIME_PEEK_LIVE) + using MobileGL::MG_Config::TransportMode; + state.peekAvailable = true; + state.totalVerbSlots = MobileGL::MG_Remote::Client::kRemoteEmitSlotCount; + switch (MobileGL::MG_Config::Transport) { + case TransportMode::Monolith: state.transportName = "monolith"; break; + case TransportMode::InProcess: state.transportName = "inproc"; break; + default: state.transportName = "non-monolith"; break; + } + state.transportResolved = MobileGL::MG_Config::Transport != TransportMode::Monolith; + + // Active() is c0's one deliberately non-aborting accessor: "does a session exist" has a + // legitimate no. Everything below it is only reached through a live session, so nothing + // here can trip one of c0's Fatal stubs. + MobileGL::MG_Remote::Client::ClientSession* session = + MobileGL::MG_Remote::Client::ClientSession::Active(); + state.sessionActive = session != nullptr; + state.implementedVerbs = MobileGL::MG_Remote::Client::ImplementedVerbCount(); + if (session != nullptr) { + // Encoder() returns the member; EmitSeq() returns m_emitSeq. Neither is a stub, and + // neither emits anything - this is a read. + state.emitSeq = session->Encoder().EmitSeq(); + } +#endif + return state; + } + + std::string SplitRuntimeSkipReason() { + const SplitRuntimeState state = PeekSplitRuntime(); + if (!state.peekAvailable) { + return "this build did not compile MG_Remote (no -DMOBILEGL_BUILD_DISAGGREGATED=ON), or " + "this is the Android binary, which links the shipping libMobileGL.so built " + "-fvisibility=hidden and can reach no internal symbol. MOBILEGL_TRANSPORT is " + "ACCEPTED AND SILENTLY IGNORED in such a build (CONTRACT-P5 5), so a green here " + "would be a monolith run under a name that says split"; + } + if (!state.transportResolved) { + return "MG_Config::Transport resolved to '" + state.transportName + + "', not to a split transport. The variable is read from the process, not from a " + "log line - a DEBUG-level pull build prints the same KEY=VALUE string out of " + "ConfigLoader's env dump (review M-5). Check MOBILEGL_TRANSPORT reached this " + "process"; + } + if (!state.sessionActive) { + return "MG_Remote::Client::ClientSession::Active() is null: no client session exists in " + "this process. c0 shipped Start() as a Fatal stub and Active() as a deliberate " + "null, so this is what 'packages s1 (construction and handshake) and c1 have not " + "landed' looks like from inside a running test. The entry stays registered (gate " + "G14) and skips rather than passing against the monolith path"; + } + if (state.implementedVerbs == 0) { + return "MG_Remote::Client::ImplementedVerbCount() is 0 of " + + std::to_string(state.totalVerbSlots) + + ": the emit table has no real emitter, so every verb this scenario issues would " + "take the Fatal{UnmigratedVerb} arm or fall through. Package c1 owns it"; + } + return {}; + } + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Harness/SplitRuntimePeek.h b/MobileGL/MG_IntegrationTest/Harness/SplitRuntimePeek.h new file mode 100644 index 00000000..3ee52772 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/SplitRuntimePeek.h @@ -0,0 +1,82 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Harness/SplitRuntimePeek.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 +// +// WHETHER THIS PROCESS IS REALLY RUNNING SPLIT, asked of the process rather than of the source +// tree. This is what arms every `DirectGLES.Split.` entry. +// +// WHY IT REPLACED A CONTENT PROBE, and the lesson is worth the paragraph. The first version of +// this arming condition was a CMake `file(STRINGS ... REGEX)` conjunction: "some source under +// MG_Remote/Client names the c1 symbols AND no source under Client or Server still matches +// `Fatal.Unimplemented`". Review finding M-1 did not argue with it, it PERFORMED it: a +// `sed -i 's/Fatal{Unimplemented/Fatal{NotYetImplemented/'` over c0's six stub files - every +// entry point still ending in std::abort(), RemoteEmitTable() still aborting on sight, +// ImplementedVerbCount() still returning 0 - armed all eleven lanes and EIGHT OF THEM WENT GREEN +// having run monolith end to end. A comment line containing the marker did the opposite and kept +// them dark forever (M-2), and two further stub files outside the two probed directories made a +// partial arm possible (M-3). +// +// The general form of that defect: A STATEMENT ABOUT SOURCE TEXT CAN ALWAYS BE FALSIFIED BY +// EDITING SOURCE TEXT, and the people most likely to edit it are the ones landing the packages +// the probe is watching for. A statement about what the process actually did cannot. So the three +// facts below are read out of the running process, and each is structurally impossible in a +// monolith build: +// +// 1. `MG_Config::Transport != Monolith`. In a build without MOBILEGL_BUILD_DISAGGREGATED, +// `Transport` is a `constexpr` Monolith (Config.h:514) and this whole translation unit is +// compiled out. Read from the VARIABLE, never from a log line - the log-grep spelling of +// this question is satisfied by a DEBUG-level pull build's `Config: Accepted env variable: +// MOBILEGL_TRANSPORT=inproc` (review finding M-5). +// 2. `ClientSession::Active() != nullptr`. c0 made this one deliberately return null rather +// than Fatal, because "does a session exist" has a legitimate "no" - it is the monolith +// answer (ClientSession.cpp:31). So it is exactly "a client session exists in this process", +// and no amount of editing stub MESSAGES makes a null pointer non-null. +// 3. `ImplementedVerbCount() > 0`. c0's stub returns 0; the contract gives this function the +// job of making "a table that silently lost an emitter" distinguishable from "a table that +// never had one" (EmitTables.h). Zero means there is no emitter to test. +// +// And one BEHAVIOURAL fact, which is the half that says the run went through the wire rather than +// merely that it could have: `ClientSession::Active()->Encoder().EmitSeq()`, the highest record +// ordinal this client has produced. A scenario that armed, drew, and emitted nothing has a +// sequence that did not move, and that is the shape of an emit table that resolves the transport +// and then falls through to the driver. +// +// Every entry point returns false, touching nothing, where the state is out of reach: in a build +// that never compiled MG_Remote, and on Android where this module links the shipping +// libMobileGL.so built -fvisibility=hidden. A caller that gets false must SKIP. + +#pragma once + +#include + +namespace MGITest { + + // What this process can say about itself. Every field is false/0 where the peek cannot look. + struct SplitRuntimeState { + // The peek is compiled in at all (MOBILEGL_BUILD_DISAGGREGATED, not Android). + bool peekAvailable = false; + // MG_Config::Transport != Monolith - this process RESOLVED a split transport. + bool transportResolved = false; + // The resolved transport, for a message: "monolith", "inproc", "spawn", "unix", "pipe". + std::string transportName = "monolith"; + // ClientSession::Active() != nullptr. + bool sessionActive = false; + // ImplementedVerbCount(), out of kRemoteEmitSlotCount (71). + unsigned int implementedVerbs = 0; + unsigned int totalVerbSlots = 0; + // The encoder's highest produced record ordinal, or 0 when there is no session. + unsigned long long emitSeq = 0; + }; + + SplitRuntimeState PeekSplitRuntime(); + + // Empty when this process is a real split run that can be asserted about; otherwise the + // reason to GTEST_SKIP() with, naming the first fact that is not true and the package that + // owns it. + std::string SplitRuntimeSkipReason(); + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/PersistentCoherentMapScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/PersistentCoherentMapScenario.cpp new file mode 100644 index 00000000..c6e08c73 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/PersistentCoherentMapScenario.cpp @@ -0,0 +1,494 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/PersistentCoherentMapScenario.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 +// +// Scenario - A PERSISTENT|WRITE|COHERENT MAPPING, WRITTEN THROUGH WITH NO GL CALL ANNOUNCING IT. +// +// Target C of P5's reduced path, spelled out in full at docs/Disaggregated/ARCHITECTURE.md:500: +// map PERSISTENT|WRITE|COHERENT, write through the pointer, MAKE NO OTHER GL CALL, draw, read +// back and check. +// +// THE "NO OTHER GL CALL" IS THE ENTIRE POINT and it is exit gate E3(b). Under the split shape the +// server has no access to the client's address space, so a coherent mapping that the application +// writes into is bytes nobody told anyone about: the client tracker has to push the dirty blocks +// at EVERY VALIDATE POINT, on its own initiative, because no glBufferSubData, no +// glFlushMappedBufferRange and no unmap will ever come. A scenario that slipped in one announcing +// call - even a glGetError between the write and the draw - would let a "push on the next explicit +// buffer operation" implementation pass, which is the implementation this gate exists to reject. +// So the write/draw pairs below are exactly `std::memcpy(...)` followed by `glDrawArrays(...)` +// with nothing in between, and the sequence is map, write, draw, WRITE AGAIN, draw again, read +// back: the second write is the one that cannot have been carried by anything the first one did. +// +// WHICH ARM IS THIS RUNNING ON. A scenario-sized buffer is far below the 16 MiB +// kLargeBufferAdoptBytes, so BufferObject::TryAdoptLargeStorage never fires here - but +// AcquireMemoryRange has an adoption of its OWN that fires for any PERSISTENT|WRITE map that is +// not FLUSH_EXPLICIT (BufferObject.cpp:645-661). So the same test lands in the ADOPTED arm (the +// resource owner minted host-visible coherent storage, the CPU shadow was released, the +// application writes straight into GPU-visible memory) or in the EMULATED arm (the owner +// declined, the shadow is the truth, and the client has to push blocks) depending on driver and +// build - and those are completely different code paths. MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION +// does NOT separate them: it guards the 16 MiB path this buffer never reaches. +// +// So the lane DECLARES the arm (MGITEST_PERSISTENT_MAP_ARM, Harness/SplitLane.h) and +// TheMapLandsInTheArmItsLaneDeclares asserts it landed there, reading the library's own summary +// line. R-6 pins the split arm at T2 = emulated. The observable that separates them is +// `pmap` (persistent-map-push bytes, PipeStats ByteClass::PersistentMapPush): a push happens if +// and only if the acquisition was DECLINED, so pmap > 0 IS the black-box spelling of exit gate +// E3(d), "MGPipeApplyMapPersistent never returns a non-null pointer under split". It is the only +// spelling available from here - an adopted inproc store still renders correctly, because the +// address really is valid in this process, which is precisely why E3(d) is worth gating at all - +// and a direct count of declines would need a counter from packages b1/v1. +// +// `mpr` (map-persistent-roundtrips) is counted per ACQUISITION ATTEMPT, mint or decline +// (ARCHITECTURE.md:492), so it is the SAME NUMBER on both arms and in both transports: that is +// what makes exit gate E3(c)'s "mpr equal to the monolith arm's" checkable by one process. Both +// lanes assert the same constant against the same workload, so the equality holds by +// construction and each lane can fail on its own. + +#include +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/PersistentMapPeek.h" +#include "../Harness/PipeStatsWindow.h" +#include "../Harness/ScenarioFixture.h" +#include "../Harness/SplitLane.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + constexpr const char* kVertexSource = R"(#version 330 core +layout(location = 0) in vec2 aPos; +layout(location = 1) in vec3 aColor; +out vec3 vColor; +void main() { + vColor = aColor; + gl_Position = vec4(aPos, 0.0, 1.0); +} +)"; + + constexpr const char* kFragmentSource = R"(#version 330 core +in vec3 vColor; +out vec4 oColor; +void main() { oColor = vec4(vColor, 1.0); } +)"; + + struct Vertex { + float x, y; + float r, g, b; + }; + + // A full-viewport quad as two triangles, flat-coloured: one region readback then speaks + // for the whole draw, and an offender pixel is a real disagreement rather than an + // interpolation difference between two drivers. + std::array Quad(float r, float g, float b) { + return {{ + {-1.f, -1.f, r, g, b}, + {1.f, -1.f, r, g, b}, + {1.f, 1.f, r, g, b}, + {-1.f, -1.f, r, g, b}, + {1.f, 1.f, r, g, b}, + {-1.f, 1.f, r, g, b}, + }}; + } + + constexpr GLsizeiptr kQuadBytes = GLsizeiptr(sizeof(Vertex) * 6); + constexpr GLbitfield kCoherentFlags = GL_MAP_WRITE_BIT | GL_MAP_PERSISTENT_BIT | GL_MAP_COHERENT_BIT; + // b1-v1.md 4.1 item 7, asserted rather than commented. GL_MAP_FLUSH_EXPLICIT_BIT would + // take this buffer OUT of the client's live-persistent-map set on purpose - the + // application announces its own writes through resource_flush_range and the push ships + // nothing - so a flag added here would turn every assertion below into a statement about + // a different mechanism, and it would still draw the right pixels under monolith. + static_assert((kCoherentFlags & GL_MAP_FLUSH_EXPLICIT_BIT) == 0, + "this scenario is about the PUSH, which is defined only for a persistent " + "write map that is NOT FLUSH_EXPLICIT (BufferObject.cpp:645-661)"); + + // The ctest entry that reads the summary line sets this and nothing else does; the case + // skips everywhere else rather than racing for the lane's log (PipeStatsWindow.h). + constexpr const char* kCounterLaneMarker = "MGITEST_PMAP_LANE"; + + // Draws issued against the mapping inside the counted window. One mapping, many draws: + // "one per acquisition" (1) and "one per draw" (kDrawsInTheWindow) have to be different + // numbers or the assertion cannot tell them apart. + constexpr int kDrawsInTheWindow = 4; + + // Acquisitions the counted window performs: exactly one glMapBufferRange of exactly one + // freshly defined immutable store. + constexpr long long kExpectedRoundtrips = 1; + + bool BuildMarkerIsSet(const char* name) { + const char* value = std::getenv(name); + return value != nullptr && value[0] == '1' && value[1] == '\0'; + } + + // A store defined with the three bits and mapped once, or a reason the driver refused. + // Returns false WITHOUT touching gtest state, so it can be called from a helper: a + // GTEST_SKIP() inside a value-returning function does not compile (the macro expands to + // a bare `return`), and every earlier attempt to hide one in a factory grew a second + // "did it skip" channel that drifted from the first. + struct CoherentStore { + unsigned int vbo = 0; + void* map = nullptr; + std::string refusal; + }; + + CoherentStore MakeCoherentlyMappedQuadStore() { + CoherentStore store; + glGenBuffers(1, &store.vbo); + glBindBuffer(GL_ARRAY_BUFFER, store.vbo); + glBufferStorage(GL_ARRAY_BUFFER, kQuadBytes, nullptr, kCoherentFlags); + if (FirstGLError() != 0u) { + glDeleteBuffers(1, &store.vbo); + store.vbo = 0; + store.refusal = + "this driver has no immutable storage with GL_MAP_PERSISTENT_BIT|GL_MAP_COHERENT_BIT"; + return store; + } + // THE ONE ACQUISITION. Everything below writes through this pointer and never maps + // again: AcquireMemoryRange only attempts an adoption while the store is not yet + // resident, so a second map would emit a second map_persistent and make the counted + // window's expected mpr a function of how often the test remapped. + store.map = glMapBufferRange(GL_ARRAY_BUFFER, 0, kQuadBytes, kCoherentFlags); + if (store.map == nullptr || FirstGLError() != 0u) { + glDeleteBuffers(1, &store.vbo); + store.vbo = 0; + store.map = nullptr; + store.refusal = "glMapBufferRange(PERSISTENT|WRITE|COHERENT) was refused"; + } + return store; + } + + void DescribeAttributes() { + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), nullptr); + glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), + reinterpret_cast(2 * sizeof(float))); + glEnableVertexAttribArray(0); + glEnableVertexAttribArray(1); + } + + class PersistentCoherentMapScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + // Ready() is false both when there is no GPU and when the base SetUp skipped a + // Split lane that has no client to assert against (ScenarioFixture.h). + if (!Ready()) return; + + std::string error; + m_program = CompileProgram(kVertexSource, kFragmentSource, &error); + ASSERT_NE(m_program, 0u) << error; + + glGenVertexArrays(1, &m_vao); + glBindVertexArray(m_vao); + const CoherentStore store = MakeCoherentlyMappedQuadStore(); + if (!store.refusal.empty()) { + GTEST_SKIP() << store.refusal; + } + m_vbo = store.vbo; + m_map = store.map; + // b1-v1.md 4.1 item 1, IMMEDIATELY AFTER THE MAP and before anything else can + // change the answer. This is the assertion that decides whether the rest of the + // scenario means anything. + AssertOrRecordArm(m_vbo, "immediately after the map"); + if (IsSkipped() || HasFatalFailure()) return; + AssertMembership(m_vbo, "immediately after the map"); + if (IsSkipped() || HasFatalFailure()) return; + DescribeAttributes(); + ASSERT_EQ(FirstGLError(), 0u) << "configuring the VAO over the coherently mapped store"; + } + + // WHICH ARM. b1-v1.md 4.1 items 1 and 5: IsBackendPersistentMapped() false is the + // emulated arm - the resource owner DECLINED, the CPU shadow is still the truth, and + // the client has to push the mapping's dirty blocks. True is the adopted arm, where + // MGPipeApplyMapPersistent handed back a pointer and the shadow was released. + // + // Under inproc BOTH ARMS RENDER CORRECTLY, because an adopted store's address really + // is valid in this process - which is exactly why exit gate E3(d) has to be checked + // and cannot be inferred from pixels. Under spawn the adopted arm would be a wild + // pointer, so this one assertion is what makes E3 mean the same thing in P5 and P6. + // + // The lane DECLARES the arm it expects (MGITEST_PERSISTENT_MAP_ARM); a lane that + // declares none RECORDS it, because which arm AcquireMemoryRange takes for a + // sub-16-MiB map is a property of the driver - llvmpipe mints, a device may decline - + // and a monolith lane that pinned one would be red on hardware for no defect. + void AssertOrRecordArm(unsigned int vbo, const char* when) { + const std::string declared = SplitLane::DeclaredPersistentMapArm(); + if (!PersistentMapPeekAvailable()) { + if (!declared.empty()) { + GTEST_SKIP() << "this lane declares the " << declared + << " arm but Harness/PersistentMapPeek cannot look in this " + "build (on Android this module links the shipping " + "libMobileGL.so, built -fvisibility=hidden, so no internal " + "symbol resolves). 'Could not look' is not 'it was " + << declared << "'."; + } + RecordProperty("persistent_map_arm", "unknown"); + return; + } + bool adopted = false; + ASSERT_TRUE(PeekBufferIsAdoptedPersistentMap(vbo, &adopted)) + << "no frontend BufferObject behind GL buffer " << vbo << " " << when; + const char* arm = adopted ? "adopted" : "emulated"; + RecordProperty("persistent_map_arm", arm); + if (declared.empty()) return; + ASSERT_EQ(declared, std::string(arm)) + << "the lane declared the " << declared << " arm and IsBackendPersistentMapped() " + << when << " says " << arm + << ". R-6 pins the split arm at adopt tier T2 = declined = emulated, and the two " + "arms are completely different code paths: adopted means the CPU shadow was " + "released and the application writes straight into storage the resource owner " + "minted, emulated means the shadow is the truth and every validate point has " + "to push blocks. Under inproc both render correctly, so pixels cannot tell " + "them apart - this is exit gate E3(d) and it is the only thing that can."; + } + + // b1-v1.md 4.1 item 2: the client's live-persistent-map set is meant to be exactly + // SyncPersistentMappedRange's early-out chain. A drift between the two stops the push + // silently; asking here makes it a named failure instead. + void AssertMembership(unsigned int vbo, const char* when) { + if (!PersistentMapTrackerAvailable()) { + RecordProperty("persistent_map_membership", "unavailable"); + return; + } + bool live = false; + ASSERT_TRUE(PeekBufferIsLivePersistentMap(vbo, &live)) + << "the tracker is compiled in but could not answer for GL buffer " << vbo << " " << when; + EXPECT_TRUE(live) + << "a PERSISTENT|WRITE|COHERENT map that is not FLUSH_EXPLICIT is a member of " + "the client's live-persistent-map set by construction, and it is not one " + << when + << ". The set is supposed to BE SyncPersistentMappedRange's early-out chain; if " + "they have drifted, the push stops shipping this buffer's blocks and nothing " + "else says so."; + } + + void TearDown() override { + if (!Ready()) return; + glBindVertexArray(0); + if (m_vbo != 0) { + glBindBuffer(GL_ARRAY_BUFFER, m_vbo); + if (m_map != nullptr) glUnmapBuffer(GL_ARRAY_BUFFER); + glBindBuffer(GL_ARRAY_BUFFER, 0); + glDeleteBuffers(1, &m_vbo); + } + if (m_vao != 0) glDeleteVertexArrays(1, &m_vao); + if (m_program != 0) glDeleteProgram(m_program); + m_vbo = m_vao = m_program = 0; + m_map = nullptr; + } + + // Everything the draw needs, set once, so that the write/draw pairs below are + // memcpy + glDrawArrays and nothing else. + void ArmTheDrawState() { + HeadlessGL& gl = Gl(); + BindDefaultFramebuffer(); + glViewport(0, 0, gl.Width(), gl.Height()); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_DEPTH_TEST); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glUseProgram(m_program); + glBindVertexArray(m_vao); + EXPECT_EQ(FirstGLError(), 0u) << "arming the draw state"; + } + + // THE WHOLE CONTRACT, IN TWO STATEMENTS. Nothing may be inserted between them - no + // glGetError, no glBindBuffer, no assertion that calls into GL. See the file header. + static void WriteThroughTheMapThenDraw(void* map, float r, float g, float b) { + const std::array quad = Quad(r, g, b); + std::memcpy(map, quad.data(), sizeof(Vertex) * quad.size()); + glDrawArrays(GL_TRIANGLES, 0, 6); + } + + // The whole surface minus an 8-pixel inset, so a primitive edge cannot contribute an + // offender. + ::testing::AssertionResult WholeSurfaceIs(const Image& image, const char* color, + const std::string& when) { + return RegionIsMostly(image, 8, image.Width() - 9, 8, image.Height() - 9, color, 0.0, when); + } + + unsigned int m_program = 0; + unsigned int m_vao = 0; + unsigned int m_vbo = 0; + void* m_map = nullptr; + }; + + } // namespace + + // E3(b): map, write, draw, WRITE AGAIN, draw again, read back. The second write is announced + // by nothing at all, so an implementation that pushed on an explicit buffer operation fails + // here and only here. + TEST_F(PersistentCoherentMapScenario, TwoWritesThroughTheCoherentPointerEachReachTheirOwnDraw) { + if (!Ready() || IsSkipped()) return; + ArmTheDrawState(); + + WriteThroughTheMapThenDraw(m_map, 0.0f, 1.0f, 0.0f); + const Image afterFirst = ReadPixels(Gl().Width(), Gl().Height()); + EXPECT_EQ(FirstGLError(), 0u); + EXPECT_TRUE(WholeSurfaceIs(afterFirst, "green", + "the first write through a PERSISTENT|WRITE|COHERENT mapping, with " + "no GL call announcing it")); + + // The second write. Whatever carried the first one - an unmap, a flush, the definition + // itself - is in the past; only a push taken at this draw's validate point can carry it. + WriteThroughTheMapThenDraw(m_map, 1.0f, 0.0f, 0.0f); + const Image afterSecond = ReadPixels(Gl().Width(), Gl().Height()); + EXPECT_EQ(FirstGLError(), 0u); + EXPECT_TRUE(WholeSurfaceIs(afterSecond, "red", + "the SECOND write through the same mapping, announced by nothing: " + "this is exit gate E3(b), and a 'push on the next explicit buffer " + "operation' implementation reads back the FIRST write's colour here")); + // E3(d) again, AFTER the draws: b1-v1.md 4.1 item 5 asks that the arm hold across the + // whole scenario, not only at the map. A late adoption - the owner declining once and + // minting on a later validate point - would leave the first assertion true and this one + // false, and would be invisible in every pixel this case reads. + AssertOrRecordArm(m_vbo, "after both writes and both draws"); + Gl().EndFrame(); + } + + // The same claim across a frame boundary, because the dirty-block set is per-frame state on + // the client and a tracker that cleared it at Present without pushing would pass the case + // above and fail this one. + TEST_F(PersistentCoherentMapScenario, AWriteAfterAFrameBoundaryReachesTheNextFramesDraw) { + if (!Ready() || IsSkipped()) return; + ArmTheDrawState(); + + WriteThroughTheMapThenDraw(m_map, 0.0f, 1.0f, 0.0f); + const Image first = ReadPixels(Gl().Width(), Gl().Height()); + EXPECT_TRUE(WholeSurfaceIs(first, "green", "frame 0's write through the mapping")); + Gl().EndFrame(); + + ArmTheDrawState(); + WriteThroughTheMapThenDraw(m_map, 1.0f, 0.0f, 0.0f); + const Image second = ReadPixels(Gl().Width(), Gl().Height()); + EXPECT_EQ(FirstGLError(), 0u); + EXPECT_TRUE(WholeSurfaceIs(second, "red", + "frame 1's write through the SAME mapping, after a Present")); + AssertOrRecordArm(m_vbo, "after a frame boundary and two draws"); + Gl().EndFrame(); + } + + // E3(c) and the black-box half of E3(d). ONE reading case, in a lane of its own, for the + // reason PipeStatsWindow.h gives: the library truncates its log per process, so two readers + // in one lane race under `ctest -j`. + TEST_F(PersistentCoherentMapScenario, TheMapLandsInTheArmItsLaneDeclares) { + if (!Ready() || IsSkipped()) return; + if (!BuildMarkerIsSet(kCounterLaneMarker)) { + GTEST_SKIP() << "not the counting lane: " << kCounterLaneMarker + << " is set only by the *.PersistentMapArm.* entries, which give this case " + "a private MOBILEGL_LOG_FILE_PATH and a RESOURCE_LOCK on it"; + } + if (!BuildMarkerIsSet("MGITEST_PIPE_PUSH_BUILD")) { + GTEST_SKIP() << "pull build: mpr= lives in the cso[ bracket, which is compiled only " + "under MOBILEGL_PIPE_PUSH, so the summary line does not carry it"; + } + if (!BuildMarkerIsSet("MGITEST_PIPE_RESOURCE_EMITTER_PRESENT")) { + GTEST_SKIP() << "no MG_Impl/Pipe source emits MapPersistentRoundtrips on this tree, so " + "mpr= is structurally zero and an assertion about it would be a " + "statement about nothing"; + } + + Gl().EndFrame(); // close the setup window; SetUp's own store and map go in it + + // One acquisition inside the counted window: one fresh immutable store, mapped once. + const CoherentStore store = MakeCoherentlyMappedQuadStore(); + if (!store.refusal.empty()) { + GTEST_SKIP() << store.refusal; + } + AssertOrRecordArm(store.vbo, "immediately after the counted window's map"); + if (IsSkipped() || HasFatalFailure()) return; + AssertMembership(store.vbo, "immediately after the counted window's map"); + if (IsSkipped() || HasFatalFailure()) return; + glBindVertexArray(m_vao); + glBindBuffer(GL_ARRAY_BUFFER, store.vbo); + DescribeAttributes(); + ArmTheDrawState(); + + // ... and then a frame's worth of traffic through it. Four writes and four draws must + // still cost ONE acquisition. + for (int draw = 0; draw < kDrawsInTheWindow; ++draw) { + WriteThroughTheMapThenDraw(store.map, 0.0f, 1.0f, 0.0f); + } + const Image image = ReadPixels(Gl().Width(), Gl().Height()); + EXPECT_EQ(FirstGLError(), 0u); + EXPECT_TRUE(WholeSurfaceIs(image, "green", + "the draws inside the counted window; if they never landed, every " + "number below is a number about nothing")); + + Gl().EndFrame(); // the swap that emits the window covering exactly the work above + + const PipeStatsWindow::Window window = PipeStatsWindow::LastFromLaneLog(); + ASSERT_TRUE(window.found) << "no 'MGPipe stats:' line in " << PipeStatsWindow::LibraryLogPath() + << ": either MOBILEGL_PIPE_STATS / MOBILEGL_PIPE_STATS_PERIOD did " + "not reach the process, or nothing reached PipeStats::OnPresent."; + RecordProperty("stats_line", window.line.c_str()); + + const long long roundtrips = PipeStatsWindow::CounterOrAbsent(window, "mpr"); + ASSERT_GE(roundtrips, 0) << "the summary line carries no mpr= field: " << window.line; + EXPECT_EQ(roundtrips, kExpectedRoundtrips) + << "one PERSISTENT|WRITE|COHERENT map of one freshly defined store is ONE acquisition " + "attempt, mint or decline (ARCHITECTURE.md:492). This window mapped once and drew " + << kDrawsInTheWindow << " times, so " << kExpectedRoundtrips << " is the whole cost; " + << kDrawsInTheWindow + << " would mean the acquisition moved onto the draw path. The number is counted as " + "ATTEMPTS precisely so that it is the SAME on the adopted and the emulated arm and " + "under both transports - which is what makes exit gate E3(c)'s 'mpr equal to the " + "monolith arm's' checkable by one process. It reported: " + << window.line; + + // E3(c)'s second half: the bytes the push actually shipped. `pmap` is bytes PUSHED because + // a persistently mapped range had to be published, so on the emulated arm a window with + // four writes and four draws through a live coherent mapping cannot be zero. + // + // THE ARM ITSELF IS NOT DERIVED FROM THIS NUMBER. It is asserted from + // IsBackendPersistentMapped() at the map and again after the draws (b1-v1.md 4.1 items 1 + // and 5), which is the direct observable; pmap is the CONSEQUENCE and is checked as a + // cross-check. Deriving the arm from pmap would have made "the push was never wired" and + // "the store was adopted" the same reading, and they are different defects with different + // owners. + const double pushedBytes = PipeStatsWindow::CounterAsDoubleOrAbsent(window, "pmap"); + ASSERT_GE(pushedBytes, 0.0) << "the summary line carries no pmap= field: " << window.line; + RecordProperty("persistent_map_push_bytes_per_frame", std::to_string(pushedBytes).c_str()); + RecordProperty("map_persistent_roundtrips", std::to_string(roundtrips).c_str()); + + if (SplitLane::DeclaredPersistentMapArm() == "emulated") { + EXPECT_GT(pushedBytes, 0.0) + << "the emulated arm pushes the mapping's dirty blocks at every validate point, so " + "a window with " + << kDrawsInTheWindow + << " writes and as many draws through a live coherent mapping cannot have pushed " + "zero bytes. Zero here with MOBILEGL_IPC_PERSISTENT_BLOCK_KB at its default " + "means the push is not reaching this buffer - which is a DIFFERENT defect from " + "the store having been adopted, and the arm assertion above has already ruled " + "that one out. Zero with the knob set to 0 is exit gate E3(a)'s negative control " + "working as intended. Line: " + << window.line; + } else { + // Recorded, not asserted: on this arm the number is whatever the driver's adoption + // decision makes it, and it is the figure MEASUREMENTS wants beside the split one. + RecordProperty("persistent_map_push_note", + "recorded only - this lane declares no emulated arm"); + } + + unsigned int vbo = store.vbo; + glBindVertexArray(0); + glBindBuffer(GL_ARRAY_BUFFER, vbo); + glUnmapBuffer(GL_ARRAY_BUFFER); + glBindBuffer(GL_ARRAY_BUFFER, 0); + glDeleteBuffers(1, &vbo); + } + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/TriangleScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/TriangleScenario.cpp new file mode 100644 index 00000000..7f0e67ce --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/TriangleScenario.cpp @@ -0,0 +1,210 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/TriangleScenario.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 +// +// Scenario - THE SMALLEST THING THAT DRAWS: one VBO, one program, one VAO, a clear, a +// VBO-backed glDrawArrays and a glReadPixels. +// +// This is target B of P5's reduced path (BRIEF-P5 4). It is an ORDINARY GL scenario and runs +// in every lane; the DirectGLES.Split. entries run the same two cases with +// MOBILEGL_TRANSPORT=inproc, where the same body is the smallest workload that crosses a real +// ring. Four things about its shape are decisions rather than defaults, and all four come from +// the measured verb census (~/w7/notes/p5/verb-census.md): +// +// 1. THE DRAW IS VBO-BACKED AND HAS NO CLIENT-ARRAY INDICES. glDrawArrays against a buffer +// bound to GL_ARRAY_BUFFER, never glDrawElements with a client pointer. That keeps +// kDrawHasUserIndices' MGHostSpan out of the first IPC frame entirely - the split filling of +// a host span is P8 - and it is why table 0 can pin kCapNeedsHostIndexBytes and +// kCapNeedsHostUboBytes at 0 for the whole of P5. +// +// 2. THERE IS NO glFlush, AND ITS ABSENCE IS THE POINT. `Flush` is not a GLFunctionsTable slot +// at all, and MG_Impl/GLImpl/Exporting/Definitions.cpp:111-112 makes glFlush() and glFinish() +// LITERALLY EMPTY BODIES - one MGLOG_D and a return. A scenario that called glFlush to order +// its readback would be ordering nothing and would still pass, which makes the ordering it +// believes in unfalsifiable. glReadPixels IS the ordering point: it is a blocking readback on +// both backends today and a SEG_REPLY round trip under split, so the pixels it returns are +// the pixels the draw produced or the case fails. +// +// 3. THE FIRST CASE ENDS WITH EndFrame(), DELIBERATELY. `Present` has ZERO call sites in +// MG_Impl - it is reached only through EGLImpl.cpp:178 -> BackendObject.cpp:396 - so a +// scenario that never swaps never touches it, and B would then exercise a STRICT SUBSET of +// what ClearThenReadPixelsScenario already covers. BRIEF-P5 4.B lists Present(67) among the +// catalogue rows this target needs, so the frame boundary is here on purpose: B is A's slot +// set plus DrawArrays, not minus Present. +// +// 4. THE SECOND CASE REDRAWS ACROSS A FRAME BOUNDARY WITHOUT REBUILDING ANYTHING. The VBO, the +// program and the VAO outlive the swap and only the clear colour changes. Under split that +// is the difference between "the client re-declares every object every frame" (which would +// pass a single-frame test and be the whole cost of the design) and a steady state; under +// monolith it is the backend's per-frame retire/aging path, which is exactly where P3a's +// respecify bug lived. + +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + // #version 330 core, because that is what the retrace lane's + // MESA_GLSL_VERSION_OVERRIDE pins and what every other scenario in this module that does + // not need a later feature uses. + constexpr const char* kVertexSource = R"(#version 330 core +layout(location = 0) in vec2 aPos; +layout(location = 1) in vec3 aColor; +out vec3 vColor; +void main() { + vColor = aColor; + gl_Position = vec4(aPos, 0.0, 1.0); +} +)"; + + constexpr const char* kFragmentSource = R"(#version 330 core +in vec3 vColor; +out vec4 oColor; +void main() { oColor = vec4(vColor, 1.0); } +)"; + + struct Vertex { + float x, y; + float r, g, b; + }; + + // A single triangle with its base at y = -0.8 and its apex at y = +0.8, so the + // interior box the cases assert on (the middle 10% of the width, a fifth of the way up) + // is far from every edge and the corner box they assert the CLEAR on is far outside it. + // Flat green: one colour over the whole primitive means an offender pixel is a real + // disagreement rather than an interpolation rounding difference between two drivers. + constexpr Vertex kTriangle[3] = { + {-0.8f, -0.8f, 0.0f, 1.0f, 0.0f}, + {0.8f, -0.8f, 0.0f, 1.0f, 0.0f}, + {0.0f, 0.8f, 0.0f, 1.0f, 0.0f}, + }; + + class TriangleScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + // Ready() is false both when there is no GPU and when the base SetUp skipped a + // Split lane that has no client to assert against (ScenarioFixture.h). + if (!Ready()) return; + + std::string error; + m_program = CompileProgram(kVertexSource, kFragmentSource, &error); + ASSERT_NE(m_program, 0u) << error; + + glGenVertexArrays(1, &m_vao); + glBindVertexArray(m_vao); + glGenBuffers(1, &m_vbo); + glBindBuffer(GL_ARRAY_BUFFER, m_vbo); + glBufferData(GL_ARRAY_BUFFER, GLsizeiptr(sizeof(kTriangle)), kTriangle, GL_STATIC_DRAW); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), nullptr); + glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), + reinterpret_cast(2 * sizeof(float))); + glEnableVertexAttribArray(0); + glEnableVertexAttribArray(1); + ASSERT_EQ(FirstGLError(), 0u) << "building the one VBO and one VAO this scenario has"; + } + + void TearDown() override { + if (!Ready() || IsSkipped()) return; + glBindVertexArray(0); + glBindBuffer(GL_ARRAY_BUFFER, 0); + if (m_vbo != 0) glDeleteBuffers(1, &m_vbo); + if (m_vao != 0) glDeleteVertexArrays(1, &m_vao); + if (m_program != 0) glDeleteProgram(m_program); + m_vbo = m_vao = m_program = 0; + } + + // Clear, draw, read back. No glFlush between the draw and the readback: see the + // file header, point 2. + Image ClearThenDrawThenRead(float clearR, float clearG, float clearB) { + HeadlessGL& gl = Gl(); + BindDefaultFramebuffer(); + glViewport(0, 0, gl.Width(), gl.Height()); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_DEPTH_TEST); + ClearTo(clearR, clearG, clearB, 1.0f); + glUseProgram(m_program); + glBindVertexArray(m_vao); + glDrawArrays(GL_TRIANGLES, 0, 3); + return ReadPixels(gl.Width(), gl.Height()); + } + + // The box inside the triangle: the middle tenth of the width, a fifth of the way up + // from the base, which is interior for the vertex set above at any surface size the + // harness uses. + void ExpectTriangleInterior(const Image& image, const char* color, const std::string& when) { + const int w = image.Width(); + const int h = image.Height(); + EXPECT_TRUE(RegionIsMostly(image, (w * 45) / 100, (w * 55) / 100, (h * 20) / 100, + (h * 30) / 100, color, 0.0, when)); + } + + // The bottom-left corner, which is below the triangle's base and left of its left + // edge, so it carries the clear and nothing else. + void ExpectClearedCorner(const Image& image, const char* color, const std::string& when) { + const int w = image.Width(); + const int h = image.Height(); + EXPECT_TRUE(RegionIsMostly(image, 0, (w * 5) / 100, 0, (h * 5) / 100, color, 0.0, when)); + } + + unsigned int m_program = 0; + unsigned int m_vao = 0; + unsigned int m_vbo = 0; + }; + + } // namespace + + // The reduced path's target B, in one case: GetCaps (reached by the first glCompileShader of + // the context, not by any verb - verb-census trap 2), Clear, DrawArrays, ReadPixels, Present. + TEST_F(TriangleScenario, AVboBackedTriangleReachesReadPixels) { + if (!Ready() || IsSkipped()) return; + + const Image image = ClearThenDrawThenRead(0.0f, 0.0f, 1.0f); + EXPECT_EQ(FirstGLError(), 0u); + ExpectTriangleInterior(image, "green", "the interior of a VBO-backed glDrawArrays triangle"); + ExpectClearedCorner(image, "blue", "the corner outside the triangle, which carries the clear"); + + // The frame boundary, deliberately (file header, point 3): this is the only thing in the + // scenario that reaches the Present slot. + Gl().EndFrame(); + } + + // Steady state: the same VBO, program and VAO across a swap, with only the clear colour + // changing. Nothing is re-created, so a client that re-declared its objects every frame and + // a backend that lost them at the frame boundary both show up here and in no single-frame + // case. + TEST_F(TriangleScenario, TheSameVboAndVaoRedrawAcrossAFrameBoundary) { + if (!Ready() || IsSkipped()) return; + + const Image first = ClearThenDrawThenRead(0.0f, 0.0f, 1.0f); + ExpectTriangleInterior(first, "green", "frame 0's triangle"); + ExpectClearedCorner(first, "blue", "frame 0's clear"); + Gl().EndFrame(); + + // Second frame: black clear, nothing else touched. + const Image second = ClearThenDrawThenRead(0.0f, 0.0f, 0.0f); + EXPECT_EQ(FirstGLError(), 0u); + ExpectTriangleInterior(second, "green", + "frame 1's triangle, drawn from the SAME VBO and VAO with no " + "re-specification of either"); + ExpectClearedCorner(second, "black", "frame 1's clear, which is the only thing that changed"); + Gl().EndFrame(); + } + +} // namespace MGITest diff --git a/tools/trace_replay/CMakeLists.txt b/tools/trace_replay/CMakeLists.txt index ae1ed03b..4534e037 100644 --- a/tools/trace_replay/CMakeLists.txt +++ b/tools/trace_replay/CMakeLists.txt @@ -322,7 +322,14 @@ function(add_trace_replay_test CASE_NAME BACKEND) CROP_Y CROP_WIDTH CROP_HEIGHT - COHERENT_AS_FLUSH) + COHERENT_AS_FLUSH + # P5. VARIANT is a NAME SUFFIX and TRANSPORT is what the variant runs with; passing + # TRANSPORT without VARIANT is a configure error below, because add_test with a + # duplicate NAME is a hard CMake error and the collision is with the very case this + # entry is a second arm of. + VARIANT + TRANSPORT + IPC_SERVER_PATH) cmake_parse_arguments(TRACE_CASE "" "${oneValueArgs}" "" ${ARGN}) foreach(required TRACE_ARCHIVE GOLDEN TARGET_CALL WIDTH HEIGHT) if(NOT TRACE_CASE_${required}) @@ -347,8 +354,38 @@ function(add_trace_replay_test CASE_NAME BACKEND) if(NOT TRACE_CASE_CROP_HEIGHT) set(TRACE_CASE_CROP_HEIGHT 0) endif() + + # --- P5: the variant suffix, and why the OUTPUT DIRECTORIES have to carry it too ---------- + # + # ARCHITECTURE.md:584 asks for the SPLIT suffix because without it the entry collides with + # the same case+backend and add_test with a duplicate NAME is a hard CMake error. + # + # THE DIRECTORIES ARE THE HALF THAT IS EASY TO FORGET AND EXPENSIVE TO GET WRONG. Both arms + # of a case write output/mobilegl.log, and run_trace_case.cmake deletes TRACE_OUTPUT_DIR + # before every run - so two arms sharing one directory means the second arm's run erases the + # first arm's log while `ctest -j` may still be reading it, and, worse, the SPLIT arm's + # refusal census is then a census of whichever arm happened to finish last. That log is the + # ONLY valid census: the console sink is compiled out of these configurations, so a `ctest -V` + # transcript reports a FALSE ZERO for Fatal{ lines. P4a shipped exactly that defect once + # already, in a different place. Same argument for TRACE_ARTIFACT_DIR, which is what CI + # uploads. + set(trace_case_suffix "") + set(trace_case_output_tag "${BACKEND}") + set(trace_case_artifact_tag "actual-images") + if(TRACE_CASE_VARIANT) + set(trace_case_suffix ".${TRACE_CASE_VARIANT}") + set(trace_case_output_tag "${BACKEND}-${TRACE_CASE_VARIANT}") + set(trace_case_artifact_tag "actual-images-${TRACE_CASE_VARIANT}") + elseif(TRACE_CASE_TRANSPORT) + message(FATAL_ERROR + "add_trace_replay_test(${CASE_NAME} ${BACKEND} TRANSPORT ${TRACE_CASE_TRANSPORT}) with no " + "VARIANT: the entry would collide with the monolith arm of the same case and backend, and " + "a duplicate add_test NAME is a hard error") + endif() + set(trace_case_name "MobileGLTraceReplay.${CASE_NAME}.${BACKEND}${trace_case_suffix}") + add_test( - NAME MobileGLTraceReplay.${CASE_NAME}.${BACKEND} + NAME ${trace_case_name} COMMAND "${CMAKE_COMMAND}" -DTRACE_REPLAY_EXE=$ -DMOBILEGL_LIBRARY=${mobilegl_trace_replay_mobilegl_library} @@ -367,17 +404,30 @@ function(add_trace_replay_test CASE_NAME BACKEND) -DTRACE_CROP_WIDTH=${TRACE_CASE_CROP_WIDTH} -DTRACE_CROP_HEIGHT=${TRACE_CASE_CROP_HEIGHT} -DTRACE_COHERENT_AS_FLUSH=${TRACE_CASE_COHERENT_AS_FLUSH} - -DTRACE_OUTPUT_DIR=${CMAKE_CURRENT_BINARY_DIR}/${CASE_NAME}/${BACKEND} - -DTRACE_ARTIFACT_DIR=${CMAKE_CURRENT_BINARY_DIR}/${CASE_NAME}/actual-images + -DTRACE_TRANSPORT=${TRACE_CASE_TRANSPORT} + -DTRACE_IPC_SERVER_PATH=${TRACE_CASE_IPC_SERVER_PATH} + -DTRACE_OUTPUT_DIR=${CMAKE_CURRENT_BINARY_DIR}/${CASE_NAME}/${trace_case_output_tag} + -DTRACE_ARTIFACT_DIR=${CMAKE_CURRENT_BINARY_DIR}/${CASE_NAME}/${trace_case_artifact_tag} -P ${MOBILEGL_TRACE_ROOT}/run_trace_case.cmake) + + # The label list gains `retrace-split` on a variant arm, for the same reason + # integration-split exists: a private second label is what lets `ctest -L retrace-split + # --no-tests=error` go RED in a build that never registered the arm, instead of greenly + # running nothing. `retrace` stays so the existing whole-label selections keep describing the + # whole set. A `;` inside a property value has to be escaped. + set(trace_case_labels retrace) + if(TRACE_CASE_VARIANT) + set(trace_case_labels "retrace\;retrace-${TRACE_CASE_VARIANT}") + string(TOLOWER "${trace_case_labels}" trace_case_labels) + endif() if(BACKEND STREQUAL "DirectGLES") - set_tests_properties(MobileGLTraceReplay.${CASE_NAME}.${BACKEND} PROPERTIES + set_tests_properties(${trace_case_name} PROPERTIES ENVIRONMENT "EGL_PLATFORM=surfaceless;LIBGL_ALWAYS_SOFTWARE=1;MESA_GL_VERSION_OVERRIDE=3.3;MESA_GLSL_VERSION_OVERRIDE=330" - LABELS retrace) + LABELS "${trace_case_labels}") else() - set_tests_properties(MobileGLTraceReplay.${CASE_NAME}.${BACKEND} PROPERTIES + set_tests_properties(${trace_case_name} PROPERTIES ENVIRONMENT "LIBGL_ALWAYS_SOFTWARE=1;MESA_GL_VERSION_OVERRIDE=3.3;MESA_GLSL_VERSION_OVERRIDE=330" - LABELS retrace) + LABELS "${trace_case_labels}") endif() endfunction() @@ -386,6 +436,26 @@ function(add_trace_replay_test_for_backends CASE_NAME) add_trace_replay_test(${CASE_NAME} DirectVulkan ${ARGN}) endfunction() +# --- P5: the SPLIT arm of the retrace matrix ------------------------------------------------- +# +# One variant entry per case that trace_cases.json marks `split: true` - today that is OpenRA +# alone, which is the only target the phase gate names and (measurably) the only fixture hydrated +# locally. DirectGLES only: the split shape's first backend is Espryt, and a DirectVulkan arm +# would be measuring a server nobody has written yet. +# +# REGISTERED ONLY UNDER MOBILEGL_BUILD_DISAGGREGATED, because the arm is a statement about a +# library that compiled MG_Remote. A build without the option accepts MOBILEGL_TRANSPORT=inproc +# and silently ignores it (CONTRACT-P5 5), so an unconditional registration would be an entry +# that passes by running monolith - and run_trace_case.cmake's split block would then be the only +# thing standing between that and a green, which is one guard too few for a name that says SPLIT. +function(add_trace_replay_split_test CASE_NAME) + if(NOT MOBILEGL_BUILD_DISAGGREGATED) + return() + endif() + add_trace_replay_test(${CASE_NAME} DirectGLES VARIANT SPLIT TRANSPORT inproc + IPC_SERVER_PATH "${CMAKE_BINARY_DIR}/libMobileGLServer.so" ${ARGN}) +endfunction() + set(MOBILEGL_TRACE_CASES_CMAKE "${CMAKE_CURRENT_BINARY_DIR}/trace_cases.cmake") set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${MOBILEGL_TRACE_ROOT}/trace_cases.json") execute_process( diff --git a/tools/trace_replay/run_trace_case.cmake b/tools/trace_replay/run_trace_case.cmake index 6f0110bf..f684c8e3 100644 --- a/tools/trace_replay/run_trace_case.cmake +++ b/tools/trace_replay/run_trace_case.cmake @@ -28,6 +28,30 @@ if(TRACE_COHERENT_AS_FLUSH) list(APPEND coherent_as_flush_args --coherent-as-flush) endif() +# --- P5: the transport, threaded as an ENVIRONMENT rather than as a replay CLI flag ---------- +# +# MOBILEGL_TRANSPORT reaches the library through the environment, so a `cmake -P` script can set +# it and the child inherits it - exactly how the MOBILEGL_PIPE_VERIFY block at the bottom of this +# file already works, and with no C++ change anywhere. The desktop CLI has no --env option +# (trace_replay_cli.cpp:116-179 is the whole option list) and the Android path already has one +# (trace-replay-ci.sh --env -> trace_replay_core.cpp's setenv block), so an environment read +# covers both directions and a new flag would buy nothing. +# +# TWO WAYS IN, ONE ASSERTION. `-DTRACE_TRANSPORT=` is what the SPLIT ctest variant passes, so +# that entry is self-describing and needs no ritual around it; exporting MOBILEGL_TRANSPORT in +# the calling process is what ~/w7/retrace_gate.py and CI's retrace-split job do over the +# UNCHANGED case names, because the gate parses a FOREIGN reference CTestTestfile.cmake whose +# name regex cannot see a variant suffix. Setting the variable from the -D FIRST means the +# assertions below read one value however it arrived. +if(DEFINED TRACE_TRANSPORT AND NOT "${TRACE_TRANSPORT}" STREQUAL "") + set(ENV{MOBILEGL_TRANSPORT} "${TRACE_TRANSPORT}") +endif() +if(DEFINED TRACE_IPC_SERVER_PATH AND NOT "${TRACE_IPC_SERVER_PATH}" STREQUAL "") + # ARCHITECTURE.md:543. P6 consumes it; P5 carries it so that "unparsed" and + # "parsed and ignored" stop being the same observation. + set(ENV{MOBILEGL_IPC_SERVER_PATH} "${TRACE_IPC_SERVER_PATH}") +endif() + if(EXISTS "${TRACE_OUTPUT_DIR}") file(REMOVE_RECURSE "${TRACE_OUTPUT_DIR}") endif() @@ -189,3 +213,99 @@ if(DEFINED ENV{MOBILEGL_PIPE_VERIFY} AND NOT "$ENV{MOBILEGL_PIPE_VERIFY}" STREQU message(STATUS "MGPipe verify: ${pipe_verify_case} armed, zero divergences, zero unmigrated reads") endif() endif() + +# --- P5: MOBILEGL_TRANSPORT, the split arm's own assertions ---------------------------------- +# +# The same problem the verify block above solves, with the same answer and one extra reason to +# need it. A retrace that exported MOBILEGL_TRANSPORT=inproc at a library configured WITHOUT +# -DMOBILEGL_BUILD_DISAGGREGATED=ON is not merely a no-op: the variable's PARSER does not exist +# in that build at all (CONTRACT-P5 5 - putting a complaint in the unconditional part of +# ConfigLoader would move a pull-build symbol and break gate G1), so the value is accepted by the +# environment and silently ignored, every frame still matches its golden, and the case reports a +# clean pass having run monolith end to end. +# +# THE LIBRARY'S OWN LOG IS THE ONLY CHANNEL a `cmake -P` script has for the difference, and it is +# also THE ONLY VALID REFUSAL CENSUS. A `ctest -V` transcript is a FALSE ZERO for Fatal{...} +# lines: the console sink is compiled out of the configurations these lanes run, so the aborts +# reach output/mobilegl.log and nowhere else. That is why TRACE_OUTPUT_DIR and TRACE_ARTIFACT_DIR +# carry the variant - without it both arms write one output/mobilegl.log, the second run wipes +# the first, and the census silently becomes a census of one arm. P4a shipped that defect once +# already; this is the same defect in a new place, pre-empted. +# +# Three demands, all silent when MOBILEGL_TRANSPORT is unset or "monolith": +# * mobilegl.log exists - the replay wrote one, so the library was loaded and logging; +# * it carries ConfigLoader's inproc line, which is emitted ONLY by a build that compiled the +# parser AND resolved the value to InProcess. This is the falsifiable half; +# * it carries no Fatal{ at all. Under split the emit table raises +# Fatal{UnmigratedVerb, ""} for the 64 slots P5 does not implement, so a clean run of a +# reduced-path target is a run that touched none of them - and any other Fatal{ (ProtocolCorruption, +# UnmigratedPipeInput, AbiMismatch) is a real defect. The count and the distinct names are +# printed either way, because the census is the deliverable even when the run passes. +if(DEFINED ENV{MOBILEGL_TRANSPORT} AND NOT "$ENV{MOBILEGL_TRANSPORT}" STREQUAL "") + set(split_case "${TRACE_CASE_NAME} ${TRACE_BACKEND}") + if("$ENV{MOBILEGL_TRANSPORT}" STREQUAL "monolith") + message(STATUS "MGPipe split: MOBILEGL_TRANSPORT=monolith, no split assertions for ${split_case}") + elseif(NOT EXISTS "${mobilegl_log}") + message(FATAL_ERROR + "MOBILEGL_TRANSPORT=$ENV{MOBILEGL_TRANSPORT} is set for ${split_case} but the run wrote " + "no ${mobilegl_log}, so there is no evidence the transport ever resolved. A split " + "retrace with no library log cannot be counted as a split retrace.") + else() + file(READ "${mobilegl_log}" split_log) + # THE DISTINCTIVE PART OF ConfigLoader's INFO LINE, not the bare KEY=VALUE - review finding + # M-5. ConfigLoader.cpp:71 logs `Config: Accepted env variable: %s=%s` for EVERY MOBILEGL_* + # variable, unconditionally, in every build including the pull one. That line is MGLOG_D, + # so at the INFO level CI and the gate use it is compiled out - but at + # MOBILEGL_LOG_ACTIVE_LEVEL=..._DEBUG it reads `Config: Accepted env variable: + # MOBILEGL_TRANSPORT=inproc` and satisfied the old substring search. Observed GREEN on a + # crafted pull-build log. The person most likely to hit that is the one who rebuilds at + # DEBUG to debug a split failure. The sentence below exists only in + # ConfigLoader::InitTransport's InProcess arm, which exists only under + # MOBILEGL_BUILD_DISAGGREGATED. + set(split_expected_marker "MOBILEGL_TRANSPORT=inproc - the MGPipe record stream") + string(FIND "${split_log}" "${split_expected_marker}" split_armed_at) + if(split_armed_at EQUAL -1) + # Say which transport was actually asked for. ConfigLoader REFUSES spawn / unix: / + # pipe: BY NAME and stays on monolith (they are P6's), so a run that set one of those + # has a different diagnosis from one that set inproc against a monolith library, and + # the old message named `inproc` either way. + set(split_refusal "") + if(NOT "$ENV{MOBILEGL_TRANSPORT}" STREQUAL "inproc") + set(split_refusal + " NOTE: this run asked for '$ENV{MOBILEGL_TRANSPORT}', which P5 does not " + "implement - ConfigLoader recognises spawn / unix: / pipe: and REFUSES them by " + "name, staying on monolith. Only 'inproc' can resolve in P5.") + endif() + message(FATAL_ERROR + "MOBILEGL_TRANSPORT=$ENV{MOBILEGL_TRANSPORT} is set for ${split_case} and the library " + "never reported resolving it: ${mobilegl_log} carries no " + "\"${split_expected_marker}\". ConfigLoader::InitTransport logs that line at INFO " + "when it selects InProcess, and it exists only in a build configured with " + "-DMOBILEGL_BUILD_DISAGGREGATED=ON - in a build without it the whole parser is " + "compiled out and the variable is accepted and ignored, which is exactly the 'the " + "split lane ran monolith and went green' failure. Check that the SPLIT runtime " + "artifact is the one at ${MOBILEGL_LIBRARY}, and that MOBILEGL_LOG_ACTIVE_LEVEL " + "admits INFO (at WARN or above the line is compiled out and this reds for no " + "defect).${split_refusal}") + endif() + # The refusal census. Recorded on every split run, pass or fail. + file(STRINGS "${mobilegl_log}" split_fatals REGEX "Fatal\\{") + list(LENGTH split_fatals split_fatal_count) + message(STATUS "MGPipe split: ${split_case} transport=$ENV{MOBILEGL_TRANSPORT}, " + "Fatal{ lines in ${mobilegl_log}: ${split_fatal_count}") + if(split_fatals) + foreach(line IN LISTS split_fatals) + message(STATUS "${line}") + endforeach() + message(FATAL_ERROR + "${split_case}: ${split_fatal_count} MGPipe Fatal(s) under MOBILEGL_TRANSPORT=" + "$ENV{MOBILEGL_TRANSPORT}. Fatal{UnmigratedVerb, \"\"} is one of the 64 emit-table " + "slots P5 leaves unimplemented (R-4) - if the reduced path reached it, either the verb " + "census is wrong or this case is not on the reduced path; Fatal{ProtocolCorruption, ...} " + "is a record that crossed the wire without declaring its bytes (CONTRACT-P5 rule A); " + "Fatal{UnmigratedPipeInput, ...} is a missing row in the field-ownership table. None of " + "them is silenced here: this log is the only place they appear, because the console sink " + "is compiled out of the configurations this lane runs.") + endif() + endif() +endif() diff --git a/tools/trace_replay/trace_cases.json b/tools/trace_replay/trace_cases.json index 03ef2c0d..59bcc234 100644 --- a/tools/trace_replay/trace_cases.json +++ b/tools/trace_replay/trace_cases.json @@ -14,6 +14,7 @@ { "name": "OpenRA", "verify": true, + "split": true, "trace_archive": "openra.tgz", "trace_file": "openra.trace", "golden": "openra.0000031249.png", diff --git a/tools/trace_replay/trace_cases.py b/tools/trace_replay/trace_cases.py index 8203eac5..204fa5b4 100644 --- a/tools/trace_replay/trace_cases.py +++ b/tools/trace_replay/trace_cases.py @@ -8,11 +8,48 @@ from pathlib import Path TRACE_CASES_JSON = Path(__file__).with_name("trace_cases.json") CI_BACKENDS = ("DirectGLES", "DirectVulkan") +# Every key a case or the defaults block may carry. An UNKNOWN key is a hard error rather than a +# silent no-op, which is review finding N-4: `"split": true` mistyped as `"splitt": true` loaded +# clean, the split subset became [], the GitHub matrix became {"include":[]}, and `retrace-split` +# was skipped with no red anywhere. Every other way of getting `split` wrong already raised +# (`"ci": false`, a backend list without DirectGLES, a non-bool value) - the typo was the one hole, +# and it is the shape that makes a whole CI job quietly stop existing. +# +# Adding a key means adding it here, deliberately, in the same commit. That is the point. +KNOWN_CASE_KEYS = frozenset({ + "name", + "trace_archive", + "trace_file", + "golden", + "alternate_golden", + "target_call", + "width", + "height", + "ssim_threshold", + "crop_x", + "crop_y", + "crop_width", + "crop_height", + "coherent_as_flush", + "timeout_seconds", + "ci", + "ci_backends", + "verify", + "split", + "avoid_angle_llvmpipe_explicit_lod_bias", +}) + def load_trace_case_manifest(path=TRACE_CASES_JSON): with Path(path).open("r", encoding="utf-8") as file: manifest = json.load(file) defaults = manifest.get("defaults", {}) + unknown_defaults = sorted(set(defaults) - KNOWN_CASE_KEYS) + if unknown_defaults: + raise ValueError( + f"unknown key(s) in the defaults block: {', '.join(unknown_defaults)}. " + f"Known keys are {', '.join(sorted(KNOWN_CASE_KEYS))}" + ) cases = [] seen = set() for case in manifest.get("cases", []): @@ -20,6 +57,13 @@ def load_trace_case_manifest(path=TRACE_CASES_JSON): name = merged.get("name") if not name: raise ValueError("trace case is missing name") + unknown = sorted(set(case) - KNOWN_CASE_KEYS) + if unknown: + raise ValueError( + f"unknown key(s) for {name}: {', '.join(unknown)}. A mistyped flag loads clean and " + f"turns its whole CI subset into an empty matrix, which GitHub skips with no red. " + f"Known keys are {', '.join(sorted(KNOWN_CASE_KEYS))}" + ) if name in seen: raise ValueError(f"duplicate trace case: {name}") seen.add(name) @@ -36,6 +80,23 @@ def load_trace_case_manifest(path=TRACE_CASES_JSON): raise ValueError( f"{name} is marked verify but excluded from CI, so the verify matrix would drop it" ) + # "split" opts a case into the MOBILEGL_TRANSPORT=inproc retrace subset, the same shape + # and the same reason as "verify" above: a typo has to be a loud manifest error in every + # consumer, not a subset that is quietly one case short. The split arm additionally runs + # DirectGLES ONLY - the server the arm exercises is Espryt's - so a case that excluded + # DirectGLES from CI would leave the split matrix with nothing to run. + split = merged.get("split", False) + if not isinstance(split, bool): + raise ValueError(f"split must be true or false for {name}") + if split and not merged.get("ci", True): + raise ValueError( + f"{name} is marked split but excluded from CI, so the split matrix would drop it" + ) + if split and "DirectGLES" not in ci_backends(merged): + raise ValueError( + f"{name} is marked split but does not run DirectGLES in CI; the split arm is " + f"DirectGLES-only, so the entry would be registered with no backend" + ) cases.append(merged) return {"defaults": defaults, "cases": cases} @@ -92,6 +153,16 @@ def verify_trace_cases(cases): return [case for case in cases if case.get("verify", False)] +def split_trace_cases(cases): + """The subset the split (MOBILEGL_TRANSPORT=inproc) CI mode retraces. + + P5's phase gate names exactly one: OpenRA, at SSIM >= 0.99. It is also the only fixture that + is hydrated locally, so keeping the subset explicit in the manifest is what stops a later + phase from widening the arm into an LFS fetch by accident. + """ + return [case for case in cases if case.get("split", False)] + + def ci_backends(case): backends = case.get("ci_backends") if backends is None: @@ -122,6 +193,16 @@ def github_verify_matrix(cases): return github_test_matrix(verify_trace_cases(cases)) +def github_split_matrix(cases): + """{backend, case} for the split arm. DirectGLES only - see split_trace_cases.""" + return { + "include": [ + {"backend": "DirectGLES", "case": case["name"]} + for case in split_trace_cases(cases) + ] + } + + def github_apk_matrix(cases): backends = { "DirectGLES": {"name": "DirectGLES", "gpu": "software"}, @@ -158,8 +239,8 @@ def emit_cmake(cases, fixture_root): ("CROP_HEIGHT", "crop_height", False), ("COHERENT_AS_FLUSH", "coherent_as_flush", False), ] - for case in cases: - lines.append(f"add_trace_replay_test_for_backends({cmake_quote(case['name'])}") + def emit_one(function, case): + lines.append(f"{function}({cmake_quote(case['name'])}") for cmake_key, json_key, fixture_path in keys: value = case.get(json_key) if value is None or value == "": @@ -169,6 +250,14 @@ def emit_cmake(cases, fixture_root): lines.append(f" {cmake_key} {cmake_quote(value)}") lines.append(")") lines.append("") + + for case in cases: + emit_one("add_trace_replay_test_for_backends", case) + # P5's split arm, emitted beside the monolith pair rather than in a block of its own so + # that a case and its variant always carry identical parameters. The CMake side is a + # no-op unless MOBILEGL_BUILD_DISAGGREGATED is ON. + if case.get("split", False): + emit_one("add_trace_replay_split_test", case) return "\n".join(lines) @@ -183,6 +272,7 @@ def parse_args(): "names", "github-test-matrix", "github-verify-matrix", + "github-split-matrix", "github-apk", "github-apk-matrix", "fixture-files", @@ -204,6 +294,8 @@ def main(): print(json.dumps(github_test_matrix(cases), separators=(",", ":"))) elif args.format == "github-verify-matrix": print(json.dumps(github_verify_matrix(cases), separators=(",", ":"))) + elif args.format == "github-split-matrix": + print(json.dumps(github_split_matrix(cases), separators=(",", ":"))) elif args.format == "github-apk": print(json.dumps([github_apk_case(case) for case in cases], separators=(",", ":"))) elif args.format == "github-apk-matrix":