From 7e82ea0fbf079272f399ecbba06368b4f64809c6 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 20 Sep 2026 10:16:54 -0400 Subject: [PATCH] [CI, Android] (Disaggregated): ship and verify inproc APKs on disaggregation branches --- .github/workflows/apk.yml | 81 ++++++++++++++++++++++++++++- android-plugin/app/build.gradle.kts | 13 +++++ android-plugin/trace-replay-ci.sh | 38 +++++++++++++- build.gradle | 20 ++++--- 4 files changed, 141 insertions(+), 11 deletions(-) diff --git a/.github/workflows/apk.yml b/.github/workflows/apk.yml index cd3c4fe4..fcd2481c 100644 --- a/.github/workflows/apk.yml +++ b/.github/workflows/apk.yml @@ -10,6 +10,7 @@ on: # branch runs the full lane on every push so a phase's landing is not gated on # someone remembering to dispatch the workflow by hand. - feat/disaggregated + - codex/fix-github-ci workflow_dispatch: jobs: @@ -25,6 +26,7 @@ jobs: CCACHE_MAXSIZE: 4G CCACHE_NOHASHDIR: "true" MOBILEGL_CMAKE_COMPILER_LAUNCHER: ccache + MOBILEGL_CI_DISAGGREGATED: ${{ (github.ref_name == 'feat/disaggregated' || github.ref_name == 'codex/fix-github-ci') && 'ON' || 'OFF' }} steps: - name: Checkout repo @@ -81,7 +83,13 @@ jobs: run: python update_glslang_sources.py - name: Build plugin APK - run: gradle --no-daemon -p android-plugin :app:assemblePluginRelease -Pmobilegl.apkSuffix="${GITHUB_SHA}" -Pmobilegl.logLevel=MOBILEGL_LOG_LEVEL_INFO --parallel --max-workers "$(nproc)" + run: >- + gradle --no-daemon -p android-plugin :app:assemblePluginRelease + -Pmobilegl.apkSuffix="${GITHUB_SHA}" -Pmobilegl.logLevel=MOBILEGL_LOG_LEVEL_INFO + -Pmobilegl.buildDisaggregated="${MOBILEGL_CI_DISAGGREGATED}" + -Pmobilegl.buildDisaggregatedInproc="${MOBILEGL_CI_DISAGGREGATED}" + -Pmobilegl.pipePush="${MOBILEGL_CI_DISAGGREGATED}" + --parallel --max-workers "$(nproc)" env: SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }} SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }} @@ -123,12 +131,74 @@ jobs: 66fdc867e552192d553d59095ea2e3cef4829de65c356f1fd826027b1905972e - name: Build retrace APK - run: gradle --no-daemon -p android-plugin :app:assembleTraceRelease -Pmobilegl.apkSuffix="${GITHUB_SHA}" -Pmobilegl.abis=all -Pmobilegl.debuggableRelease=true -Pmobilegl.logLevel=MOBILEGL_LOG_LEVEL_INFO --parallel --max-workers "$(nproc)" + run: >- + gradle --no-daemon -p android-plugin :app:assembleTraceRelease + -Pmobilegl.apkSuffix="${GITHUB_SHA}" -Pmobilegl.abis=all + -Pmobilegl.debuggableRelease=true -Pmobilegl.logLevel=MOBILEGL_LOG_LEVEL_INFO + -Pmobilegl.buildDisaggregated="${MOBILEGL_CI_DISAGGREGATED}" + -Pmobilegl.buildDisaggregatedInproc="${MOBILEGL_CI_DISAGGREGATED}" + -Pmobilegl.pipePush="${MOBILEGL_CI_DISAGGREGATED}" + --parallel --max-workers "$(nproc)" env: SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }} SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }} SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }} + - name: Prove disaggregated native code in both APKs + if: env.MOBILEGL_CI_DISAGGREGATED == 'ON' + run: | + python3 - <<'PY' + import hashlib, json, os, re, zipfile + from pathlib import Path + proofs = {} + for path in Path('.').rglob('compile_commands.json'): + rows = json.loads(path.read_text()) + production = [row for row in rows if row['file'].replace('\\', '/').endswith('/MobileGL/ConfigLoader.cpp')] + if not production: + continue + cache = path.with_name('CMakeCache.txt').read_text() + abi = re.search(r'^ANDROID_ABI:[^=]+=(.+)$', cache, re.MULTILINE) + if not abi: + raise SystemExit(f'no Android ABI identity beside {path}') + for row in production: + command = row.get('command') or ' '.join(row['arguments']) + for macro in ('MOBILEGL_BUILD_DISAGGREGATED', 'MOBILEGL_PIPE_PUSH'): + if not re.search(r'-D' + macro + r'=1(?:\s|$)', command): + raise SystemExit(f'{path}: production command lacks {macro}=1') + proofs.setdefault(abi[1].strip(), []).append(str(path)) + if not proofs: + raise SystemExit('no production compile commands prove a disaggregated APK build') + artifacts = [] + marker = b'Config: MOBILEGL_TRANSPORT=inproc - the MGPipe record stream' + for flavor in ('plugin', 'trace'): + stem = 'MobileGL-plugin-' + ('trace-' if flavor == 'trace' else '') + apk = Path(f'android-plugin/app/build/outputs/apk/{flavor}/release/{stem}release-{os.environ["GITHUB_SHA"]}.apk') + libraries = [] + with zipfile.ZipFile(apk) as archive: + for name in archive.namelist(): + if not re.fullmatch(r'lib/[^/]+/libMobileGL\.so', name): + continue + abi = name.split('/')[1] + library = archive.read(name) + if abi not in proofs or marker not in library: + raise SystemExit(f'{apk}:{name}: missing compile or compiled transport evidence') + libraries.append({'abi': abi, 'path': name, 'sha256': hashlib.sha256(library).hexdigest()}) + required = {'arm64-v8a', 'x86_64'} if flavor == 'trace' else {'arm64-v8a'} + if not required.issubset({library['abi'] for library in libraries}): + raise SystemExit(f'{apk}: requested ABI coverage is missing: {required}') + artifacts.append({'apk': str(apk), 'sha256': hashlib.sha256(apk.read_bytes()).hexdigest(), 'libraries': libraries}) + Path('apk-build-mode.json').write_text(json.dumps({'mode': 'disaggregated-inproc', 'source_sha': os.environ['GITHUB_SHA'], + 'production_compile_commands_by_abi': proofs, 'artifacts': artifacts}, indent=2)) + PY + + - name: Upload APK build-mode evidence + if: env.MOBILEGL_CI_DISAGGREGATED == 'ON' + uses: actions/upload-artifact@v7 + with: + name: MobileGL-apk-build-mode-${{ github.sha }} + path: apk-build-mode.json + if-no-files-found: error + - name: Show ccache stats if: always() run: ccache --show-stats @@ -338,6 +408,7 @@ jobs: if: ${{ always() && needs.build.result == 'success' && needs.android-avd.result == 'success' && needs.trace-cases.result == 'success' }} timeout-minutes: 75 env: + MOBILEGL_CI_DISAGGREGATED: ${{ (github.ref_name == 'feat/disaggregated' || github.ref_name == 'codex/fix-github-ci') && 'ON' || 'OFF' }} AVD_NAME: mobilegl-ci ANDROID_AVD_HOME: ${{ github.workspace }}/.android/avd ANDROID_HOME: ${{ github.workspace }}/.android/sdk @@ -437,6 +508,12 @@ jobs: apk_file="android-retrace-apks/MobileGL-plugin-trace-release-${GITHUB_SHA}.apk" test -f "${apk_file}" extra_retrace_args=() + if [ "${MOBILEGL_CI_DISAGGREGATED}" = "ON" ]; then + extra_retrace_args+=( + --env "MOBILEGL_TRANSPORT=inproc;MOBILEGL_IPC_ROLE_SPLIT_STATE=1;MOBILEGL_IPC_STRICT_ERRORS=1;MOBILEGL_IPC_RUN_AHEAD=1" + --require-inproc + ) + fi # Bliss needs the newer signed ANGLE variant plus sampler mipmap # min-filter downgrading on ANGLE llvmpipe. if [ "${{ matrix.backend.name }}" = "DirectGLES" ] && [ "${{ matrix.case.name }}" = "minecraft-1.21.4-fabric-iris-bliss-in-world" ]; then diff --git a/android-plugin/app/build.gradle.kts b/android-plugin/app/build.gradle.kts index d95fdccd..01dd85b9 100644 --- a/android-plugin/app/build.gradle.kts +++ b/android-plugin/app/build.gradle.kts @@ -33,6 +33,15 @@ fun Project.mobileGlAbiFilters(): List { fun Project.mobileGlCmakeCompilerLauncher(): String = (findProperty("mobilegl.cmakeCompilerLauncher") ?: System.getenv("MOBILEGL_CMAKE_COMPILER_LAUNCHER") ?: "").toString().trim() +// Match the native module's two switches, including INPROC implying the parent +// option. A D/P plugin must ask its launcher to select the transport at runtime; +// compiling MG_Remote alone leaves ConfigLoader's default on monolith. +fun Project.mobileGlDisaggregatedEnabled(): Boolean { + fun enabled(value: Any?): Boolean = value?.toString()?.trim()?.uppercase().orEmpty() in setOf("1", "ON", "YES", "TRUE", "Y") + return enabled(findProperty("mobilegl.buildDisaggregated") ?: System.getenv("MOBILEGL_BUILD_DISAGGREGATED")) || + enabled(findProperty("mobilegl.buildDisaggregatedInproc") ?: System.getenv("MOBILEGL_BUILD_DISAGGREGATED_INPROC")) +} + // Optional application-id suffix, so a development build can sit next to an // already-installed plugin instead of having to replace it - a differently // signed APK cannot upgrade one in place, and uninstalling costs the user their @@ -73,6 +82,7 @@ val mobileGlMonthlyRevision = runGit( val mobileGlApkSuffix = (findProperty("mobilegl.apkSuffix") ?: System.getenv("MOBILEGL_APK_SUFFIX") ?: mobileGlGitShortHash) .toString() .ifBlank { "nogit" } +val mobileGlDisaggregated = mobileGlDisaggregatedEnabled() val pluginRendererConfig = buildJsonValue { renderer( @@ -83,6 +93,7 @@ val pluginRendererConfig = buildJsonValue { dlopenLibPaths = emptyList(), env = buildEnvs { normal("LIBGL_ES", "3") + if (mobileGlDisaggregated) normal("MOBILEGL_TRANSPORT", "inproc") selectable( key = "MOBILEGL_BACKEND_TYPE", title = RendererConfig.MetaString("mobilegl_backend_type_title"), @@ -127,11 +138,13 @@ android { put("LIBGL_ES", "3") put("POJAV_RENDERER", "opengles3") put("MOBILEGL_BACKEND_TYPE", "DirectGLES") + if (mobileGlDisaggregated) put("MOBILEGL_TRANSPORT", "inproc") } pojavEnv { put("LIBGL_ES", "3") put("POJAV_RENDERER", "opengles3") put("MOBILEGL_BACKEND_TYPE", "DirectGLES") + if (mobileGlDisaggregated) put("MOBILEGL_TRANSPORT", "inproc") } }) manifestPlaceholders["appLabel"] = "MobileGL" diff --git a/android-plugin/trace-replay-ci.sh b/android-plugin/trace-replay-ci.sh index 92570ce0..9f4027c8 100644 --- a/android-plugin/trace-replay-ci.sh +++ b/android-plugin/trace-replay-ci.sh @@ -33,6 +33,7 @@ Usage: [--coherent-as-flush] \ [--dump-texture-2d CALL,TEXTURE,LEVEL,DIR] \ [--env "K=V;K=V"] \ + [--require-inproc] \ [--benchmark] \ [--benchmark-tail-frames N] \ [--benchmark-finish 0|1] \ @@ -66,6 +67,9 @@ variables to the replay process. They are applied last, immediately before libMobileGL.so is loaded, so they override every flag above; an entry with no "=" unsets the variable instead. This is the generic passthrough: a MOBILEGL_* knob that has no flag of its own needs no plumbing to be forwarded. +Pass --require-inproc to require the library's real transport-resolution log, +strict errors and separate role state, plus zero Fatal records. This checks the +APK process after replay; a host environment echo or an SSIM-only pass is not proof. EOF } @@ -126,6 +130,7 @@ avoid_angle_llvmpipe_explicit_lod_bias=0 coherent_as_flush=0 texture_2d_dumps="" env_overrides="${MOBILEGL_TRACE_ENV:-}" +require_inproc=0 benchmark=0 benchmark_tail_frames=200 benchmark_finish=1 @@ -172,6 +177,7 @@ while [ "$#" -gt 0 ]; do --coherent-as-flush) coherent_as_flush=1; shift 1 ;; --dump-texture-2d) texture_2d_dumps="$(next_arg "$@")"; shift 2 ;; --env) env_overrides="$(next_arg "$@")"; shift 2 ;; + --require-inproc) require_inproc=1; shift 1 ;; --benchmark) benchmark=1; shift 1 ;; --benchmark-tail-frames) benchmark_tail_frames="$(next_arg "$@")"; shift 2 ;; --benchmark-finish) benchmark_finish="$(next_arg "$@")"; shift 2 ;; @@ -518,7 +524,37 @@ run_retrace() { fi fi - "${PYTHON}" -c 'import json, sys; result = json.load(open(sys.argv[1], encoding="utf-8")); sys.exit(0 if result.get("passed") else f"trace replay failed: {result}")' "${result_dir}/result.json" + "${PYTHON}" -c 'import json, sys; result = json.load(open(sys.argv[1], encoding="utf-8")); sys.exit(0 if result.get("passed") else f"trace replay failed: {result}")' "${result_dir}/result.json" || return "$?" + if [ "${require_inproc}" -eq 1 ]; then + "${PYTHON}" - "${result_dir}/mobilegl.log" "${result_dir}/transport-proof.json" <<'PY' +import json +from pathlib import Path +import re +import sys + +log_path, proof_path = map(Path, sys.argv[1:]) +text = log_path.read_text(encoding="utf-8", errors="replace") if log_path.is_file() else "" +# ConfigLoader's InProcess arm emits this sentence only after selecting the +# compiled transport. "Accepted env variable: MOBILEGL_TRANSPORT=inproc" is not it. +marker = "Config: MOBILEGL_TRANSPORT=inproc - the MGPipe record stream" +configs = re.findall(r"Config: IPC[^\r\n]*", text) +fatals = re.findall(r"^.*Fatal\{.*$", text, re.MULTILINE) +required = {"strict": "1", "role-split-state": "1", "run-ahead": "1"} +config_ok = bool(configs) and all( + all(re.search(r"\b" + re.escape(key) + "=" + value + r"\b", line) for key, value in required.items()) + for line in configs +) +passed = marker in text and config_ok and not fatals +proof = {"required_transport": "inproc", "library_log": str(log_path), + "transport_resolution_marker": marker in text, "ipc_config_lines": configs, + "required_ipc_settings": required, "ipc_settings_match": config_ok, + "fatal_count": len(fatals), "fatal_lines": fatals, "passed": passed} +proof_path.write_text(json.dumps(proof, indent=2), encoding="utf-8") +if not passed: + sys.exit("trace replay failed actual inproc/strict/role/zero-Fatal proof: " + json.dumps(proof)) +print("Android retrace: real inproc transport, strict role state and zero Fatal records confirmed") +PY + fi } mkdir -p "${fixture_root}" "${result_root}" diff --git a/build.gradle b/build.gradle index 8daf5d80..9267f890 100644 --- a/build.gradle +++ b/build.gradle @@ -15,14 +15,17 @@ def mobileGlBuildServerSpike = { (rootProject.findProperty('mobilegl.buildServerSpike') ?: System.getenv('MOBILEGL_BUILD_SERVER_SPIKE') ?: 'OFF') as String } -// P2 A/B opt-in. OFF by default, so the plugin flavour and every trace APK nobody asked for -// keep the pull shape; the paired two-device A/B (docs/Disaggregated ROADMAP GO/NO-GO items -// 3-4) builds one trace APK with it ON and compares it against the OFF build. Either -// ./gradlew :app:assembleTraceDebug -Pmobilegl.pipePush=ON -// or MOBILEGL_PIPE_PUSH_APK=ON in the environment. +// Shared native shape for BOTH plugin and trace APKs. Defaults keep existing +// callers on pull; disaggregated CI explicitly selects all three switches. def mobileGlPipePush = { (rootProject.findProperty('mobilegl.pipePush') ?: System.getenv('MOBILEGL_PIPE_PUSH_APK') ?: 'OFF') as String } +def mobileGlBuildDisaggregated = { + (rootProject.findProperty('mobilegl.buildDisaggregated') ?: System.getenv('MOBILEGL_BUILD_DISAGGREGATED') ?: 'OFF') as String +} +def mobileGlBuildDisaggregatedInproc = { + (rootProject.findProperty('mobilegl.buildDisaggregatedInproc') ?: System.getenv('MOBILEGL_BUILD_DISAGGREGATED_INPROC') ?: mobileGlBuildDisaggregated()) as String +} android { namespace 'top.mobilegl.mobilegl' @@ -36,6 +39,10 @@ android { externalNativeBuild { cmake { arguments "-DMOBILEGL_LOG_ACTIVE_LEVEL=${mobileGlLogActiveLevel()}" + arguments '-DCMAKE_EXPORT_COMPILE_COMMANDS=ON' + arguments "-DMOBILEGL_PIPE_PUSH=${mobileGlPipePush()}" + arguments "-DMOBILEGL_BUILD_DISAGGREGATED=${mobileGlBuildDisaggregated()}" + arguments "-DMOBILEGL_BUILD_DISAGGREGATED_INPROC=${mobileGlBuildDisaggregatedInproc()}" } } } @@ -79,9 +86,6 @@ android { // grow a binary nothing loads. See mobileGlBuildServerSpike // above for the two ways to enable it. arguments "-DMOBILEGL_BUILD_SERVER_SPIKE=${mobileGlBuildServerSpike()}" - // P2: the push-shaped library for the paired A/B, same flavour, same - // signing, one extra CMake option. See mobileGlPipePush above. - arguments "-DMOBILEGL_PIPE_PUSH=${mobileGlPipePush()}" } } }