From 8a239177ac976074f8dded5a1274f0ac6115ed6f Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 20:33:42 -0400 Subject: [PATCH] [Feat] (Build, TraceApp): ship and exec a second native binary on android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PLAN-B.md §11 P0 lists spike A (the Android delivery chain) as a P0 deliverable, inherited verbatim from PLAN.md §15 P0; §8.1 inherits PLAN.md §11.1-§11.6, whose Android path needs a second process. Android gives an application no writable exec-able directory, so the only supported route is to name the binary lib*.so, let the packager put it in lib//, and exec it out of getApplicationInfo().nativeLibraryDir. This builds that route end to end so the spike can be answered with evidence instead of folklore. - New root option MOBILEGL_BUILD_SERVER_SPIKE (OFF, ANDROID-only) adds the MobileGLServer target from tools/spikes/server_stub/main.cpp with PREFIX "lib" / SUFFIX ".so" and -fPIE/-pie: an .so name does not exempt the file from Android's PIE requirement. Its RUNTIME_OUTPUT_DIRECTORY is pointed at CMAKE_LIBRARY_OUTPUT_DIRECTORY, because AGP packages what lands in the per-ABI library output directory and CMake would otherwise put an executable elsewhere. - The option is opt-in on both sides. The plugin flavour cannot turn it on at all, and the trace flavour builds it only when asked, with `-Pmobilegl.buildServerSpike=ON` or MOBILEGL_BUILD_SERVER_SPIKE=ON in the environment; a flavour that silently carries an executable nothing loads is the kind of thing nobody notices until it ships. Verified both ways: assembleTraceDebug -Pmobilegl.buildServerSpike=ON packages lib/arm64-v8a/libMobileGLServer.so and `file` reports "ELF 64-bit LSB pie executable, ARM aarch64 ... interpreter /system/bin/linker64, for Android 26"; the same task with no property packages only libMobileGL.so and libtrace_replay_runner.so. - The stub prints one line to stdout and writes the same line to the file named by argv[1], then exits 0. The line carries pid/ppid/uid/gid and, decisively, the child's own /proc/self/attr/current: only `u:r:untrusted_app:...` proves an ordinary app process did the exec. An `adb run-as` shell runs in a different SELinux domain, so a success there would prove nothing. - RunSpawnSpike() starts the stub with argv [serverPath, markerPath], redirects the child's stdout/stderr into a captured file (an app process has stdout on /dev/null, so a printed line would otherwise vanish), waits for it, and reports exit status, signal, the exec errno, the parent's own SELinux context, the marker content and the captured stdout - to logcat, to the returned string, and to a .report file, because the Activity finishes immediately afterwards. - The child reports the errno of a REFUSED execve through a close-on-exec pipe. Without it the one datum the spike exists to produce is lost: the parent only ever sees a wait status, in which every reason has already been flattened into one exit code, and EACCES (SELinux, or a noexec mount) versus ENOEXEC (a packager that mangled the file) are opposite verdicts for the design. A successful exec closes the write end for free, so the parent reads EOF and reports execErrno=0. - fork/execve only. The earlier draft also carried a posix_spawn arm behind `__ANDROID_API__ >= 28`, which was dead code in every configuration this repo can build - bionic declares posix_spawn from API 28 and the root CMakeLists.txt pins MOBILEGL_ANDROID_API_LEVEL to 26 and refuses to configure lower - and would have silently become the production path, untested, on a minSdk bump. Keeping the arm that actually ships means the spike measures the code the server would really use. Nothing happens between fork and execve except open/dup2/execve/write/_exit, all async-signal-safe, because the parent is a multi-threaded JVM process. - The spike lives in its own TU, spawn_spike.cpp/.hpp, listed only by the trace APK's CMakeLists. Its sibling trace_replay_core.cpp is compiled verbatim by the DESKTOP mobilegl_trace_replay runner (tools/trace_replay/CMakeLists.txt names the same file), where does not exist, so nothing Android-only may live there; spawn_spike.cpp carries an #error for anyone who adds it to that list. - The Activity runs the spike, and nothing else, when launched with the `mobilegl_spike_spawn` intent extra; that mode needs no trace, no golden and no render surface. It is a separate JNI entry point rather than another parameter on the 30-argument replay call, which it shares nothing with. - Not yet run on a device: both device locks are held by another campaign. The on-device verdict is the coordinator's step. --- CMakeLists.txt | 41 ++++ .../app/src/trace/cpp/CMakeLists.txt | 5 + .../app/src/trace/cpp/spawn_spike.cpp | 227 ++++++++++++++++++ .../app/src/trace/cpp/spawn_spike.hpp | 60 +++++ .../app/src/trace/cpp/trace_replay_jni.cpp | 19 ++ .../plugin/trace/TraceReplayActivity.java | 55 +++++ build.gradle | 17 ++ tools/spikes/server_stub/main.cpp | 89 +++++++ 8 files changed, 513 insertions(+) create mode 100644 android-plugin/app/src/trace/cpp/spawn_spike.cpp create mode 100644 android-plugin/app/src/trace/cpp/spawn_spike.hpp create mode 100644 tools/spikes/server_stub/main.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 6e2dd414..354cac15 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -21,6 +21,7 @@ option(MOBILEGL_IOS "Build MobileGL for iOS instead of macOS when # That emptiness is one of the two byte-level equalities the plan's validation # gates keep (section 10.3). option(MOBILEGL_BUILD_DISAGGREGATED "Build the MG_Remote transport layer (two-process shape)" OFF) +option(MOBILEGL_BUILD_SERVER_SPIKE "Build the P0 spike-A MobileGLServer delivery-chain executable (Android only)" OFF) set(MOBILEGL_LOG_ACTIVE_LEVEL "MOBILEGL_LOG_LEVEL_INFO" CACHE STRING "MobileGL active log level macro") set(MOBILEGL_VULKAN_LIBRARY "" CACHE FILEPATH "Vulkan loader/MoltenVK library to link for iOS builds") @@ -760,3 +761,43 @@ endif() if (ANDROID AND MOBILEGL_BUILD_INTEGRATION_TEST) add_subdirectory(MobileGL/MG_IntegrationTest) endif() + +# --------------------------------------------------------------------------- +# P0 spike A: the Android delivery chain for a second native executable. +# +# The disaggregated design needs a server process on Android (PLAN-B.md §8.1, +# inheriting PLAN.md §11.1-§11.6). An APK's only exec-able install location is +# lib//, and the packager only puts a file there if it is named lib*.so - +# so a second executable has to be built with an .so name and exec'd out of +# getApplicationInfo().nativeLibraryDir. This target is the stub that proves the +# chain end to end: it is packaged like a library, exec'd from the app's own +# untrusted_app process, and writes a marker the parent reads back. +# +# Off by default and ANDROID-only, so no shipping configuration builds it. The +# trace flavour of the plugin APK turns it on (android-plugin/build.gradle). +# --------------------------------------------------------------------------- +if (ANDROID AND MOBILEGL_BUILD_SERVER_SPIKE) + add_executable(MobileGLServer + ${CMAKE_CURRENT_SOURCE_DIR}/tools/spikes/server_stub/main.cpp) + + # An executable that is named like a shared library still has to be a real + # PIE executable: Android has refused non-PIE executables since API 21, and + # the name alone does not change what the loader demands of the file. + set_target_properties(MobileGLServer PROPERTIES + PREFIX "lib" + SUFFIX ".so" + OUTPUT_NAME "MobileGLServer" + POSITION_INDEPENDENT_CODE ON) + target_compile_options(MobileGLServer PRIVATE -fPIE) + target_link_options(MobileGLServer PRIVATE -pie) + + # AGP packages what the external native build drops into the per-ABI output + # directory, and it selects by the .so extension. CMake puts executables in + # CMAKE_RUNTIME_OUTPUT_DIRECTORY, which is not the directory AGP hands to + # CMAKE_LIBRARY_OUTPUT_DIRECTORY, so point this target's runtime output at + # the library directory when the generator gave us one. + if (CMAKE_LIBRARY_OUTPUT_DIRECTORY) + set_target_properties(MobileGLServer PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_LIBRARY_OUTPUT_DIRECTORY}") + endif() +endif() diff --git a/android-plugin/app/src/trace/cpp/CMakeLists.txt b/android-plugin/app/src/trace/cpp/CMakeLists.txt index 6474d73d..cdf595e3 100644 --- a/android-plugin/app/src/trace/cpp/CMakeLists.txt +++ b/android-plugin/app/src/trace/cpp/CMakeLists.txt @@ -234,6 +234,10 @@ target_link_libraries(glretrace_common PUBLIC retrace_common glhelpers glproc) add_library(trace_replay_runner SHARED trace_replay_core.cpp trace_replay_jni.cpp + # P0 spike A. Android-only, and deliberately its own TU: trace_replay_core.cpp is + # shared verbatim with the desktop mobilegl_trace_replay runner + # (tools/trace_replay/CMakeLists.txt), which cannot see . + spawn_spike.cpp "${CMAKE_CURRENT_LIST_DIR}/../../../../../tools/trace_replay/apitrace_fbo_dump.cpp") target_compile_features(trace_replay_runner PRIVATE cxx_std_17) @@ -249,4 +253,5 @@ target_link_libraries(trace_replay_runner retrace_common image android + log dl) diff --git a/android-plugin/app/src/trace/cpp/spawn_spike.cpp b/android-plugin/app/src/trace/cpp/spawn_spike.cpp new file mode 100644 index 00000000..b6143cc1 --- /dev/null +++ b/android-plugin/app/src/trace/cpp/spawn_spike.cpp @@ -0,0 +1,227 @@ +// P0 spike A - the Android half of the delivery chain (PLAN-B.md §8.1, inheriting +// PLAN.md §11.1-§11.6). See spawn_spike.hpp for what the spike is asking. +// +// Android-only on purpose: this TU is listed only by +// android-plugin/app/src/trace/cpp/CMakeLists.txt. Its sibling trace_replay_core.cpp is +// shared with the DESKTOP mobilegl_trace_replay runner, which has no , +// so nothing Android-specific may live there. + +#include "spawn_spike.hpp" + +#if !defined(__ANDROID__) +#error "spawn_spike.cpp is Android-only; do not add it to the desktop trace replay build" +#endif + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +// execve needs the environment the parent already has: a server process started from +// the app must inherit it, and handing it an empty one would change what is being tested. +extern "C" char** environ; + +namespace mobilegl_trace { +namespace { + +constexpr const char* kSpikeLogTag = "MobileGLTraceRunner"; + +std::string ReadWholeFile(const std::string& path) { + std::ifstream input(path, std::ios::binary); + if (!input) { + return {}; + } + std::ostringstream contents; + contents << input.rdbuf(); + std::string text = contents.str(); + while (!text.empty() && (text.back() == '\n' || text.back() == '\r' || text.back() == '\0')) { + text.pop_back(); + } + return text; +} + +// The domain this process is in. `u:r:untrusted_app:s0:...` is the whole point of the +// spike: an exec that works from an `adb run-as` shell says nothing about whether the +// app itself is allowed to do it, because that shell is a different SELinux domain. +std::string ReadSelfSelinuxContext() { + const std::string context = ReadWholeFile("/proc/self/attr/current"); + return context.empty() ? "" : context; +} + +// Starts the child with its stdout and stderr redirected into `outputPath`, reports the +// child pid through `childPid` and, when the exec itself was refused, the child's errno +// through `execErrno`. Returns 0, or the errno of a failure that happened before the +// child existed at all. +// +// fork/execve, not posix_spawn: bionic only declares posix_spawn from API 28 while +// MobileGL ships at minSdk 26 (the root CMakeLists.txt pins MOBILEGL_ANDROID_API_LEVEL +// to 26 and refuses to configure lower), so posix_spawn is not available to the shipping +// build and this is the shape the production spawn path has to take. Nothing happens +// between fork and execve except open/dup2/execve/write/_exit, all async-signal-safe, +// because the parent is a multi-threaded JVM process. +int SpawnSpikeChild(const std::string& serverPath, + const std::string& markerPath, + const std::string& outputPath, + pid_t* childPid, + int* execErrno) { + *execErrno = 0; + char* argv[] = {const_cast(serverPath.c_str()), + const_cast(markerPath.c_str()), nullptr}; + + // The errno of a refused exec is the answer this spike is here to bring back, and it + // is raised in a process that cannot return anything: by the time the parent sees a + // wait status the reason has been flattened into an exit code. So the child writes + // the raw errno into a close-on-exec pipe. A successful exec closes the write end for + // free and the parent reads EOF; a refused one leaves the four bytes behind. EACCES + // (SELinux, or a noexec mount) and ENOEXEC (a mangled or non-PIE file) are entirely + // different verdicts for the design and this is the only thing that separates them. + int report[2] = {-1, -1}; + if (pipe2(report, O_CLOEXEC) != 0) { + return errno; + } + + const pid_t forked = fork(); + if (forked < 0) { + const int forkErrno = errno; + close(report[0]); + close(report[1]); + return forkErrno; + } + if (forked == 0) { + close(report[0]); + // Without this the child's output is unobservable: an Android app process has + // stdout on /dev/null, so a printed line would vanish and the spike could not + // tell "ran and printed" apart from "never ran". + const int outputFd = open(outputPath.c_str(), O_CREAT | O_WRONLY | O_TRUNC, 0664); + if (outputFd >= 0) { + dup2(outputFd, STDOUT_FILENO); + dup2(outputFd, STDERR_FILENO); + if (outputFd != STDOUT_FILENO && outputFd != STDERR_FILENO) { + close(outputFd); + } + } + execve(serverPath.c_str(), argv, environ); + const int failure = errno; + // Only reached when the exec was refused - the one outcome this spike is about. + const ssize_t written = write(report[1], &failure, sizeof(failure)); + static_cast(written); + // 127 is the shell's convention for "could not exec" and is distinguishable from + // every status the stub itself can return. + _exit(127); + } + + close(report[1]); + int failure = 0; + ssize_t got = 0; + // Blocks until the child either execs (the write end closes, read returns 0) or + // reports why it could not. + while ((got = read(report[0], &failure, sizeof(failure))) < 0 && errno == EINTR) { + } + close(report[0]); + if (got == static_cast(sizeof(failure))) { + *execErrno = failure; + } + *childPid = forked; + return 0; +} + +} // namespace + +SpawnSpikeResult RunSpawnSpike(const SpawnSpikeRequest& request) { + SpawnSpikeResult result; + result.parentSelinuxContext = ReadSelfSelinuxContext(); + + if (request.serverPath.empty() || request.markerPath.empty()) { + result.message = "spike-spawn: serverPath and markerPath are both required"; + return result; + } + + // A stale marker from a previous run would otherwise be read back as this run's + // proof. Remove it first, so "the marker exists" can only mean the child wrote it. + unlink(request.markerPath.c_str()); + const std::string childOutputPath = request.markerPath + ".stdout"; + unlink(childOutputPath.c_str()); + + struct stat serverStat {}; + if (stat(request.serverPath.c_str(), &serverStat) != 0) { + result.spawnErrno = errno; + result.message = "spike-spawn: " + request.serverPath + " does not exist: " + + std::strerror(errno); + __android_log_print(ANDROID_LOG_ERROR, kSpikeLogTag, "%s", result.message.c_str()); + return result; + } + + pid_t childPid = -1; + int execErrno = 0; + const int spawnStatus = SpawnSpikeChild(request.serverPath, request.markerPath, + childOutputPath, &childPid, &execErrno); + if (spawnStatus != 0) { + result.spawnErrno = spawnStatus; + result.message = "spike-spawn: could not start " + request.serverPath + + ": spawnErrno=" + std::to_string(spawnStatus) + " (" + + std::strerror(spawnStatus) + ")"; + __android_log_print(ANDROID_LOG_ERROR, kSpikeLogTag, "%s (parentSelinux=%s)", + result.message.c_str(), result.parentSelinuxContext.c_str()); + return result; + } + + result.spawned = true; + result.childPid = static_cast(childPid); + result.execErrno = execErrno; + + int waitStatus = 0; + while (waitpid(childPid, &waitStatus, 0) < 0) { + if (errno != EINTR) { + result.message = "spike-spawn: waitpid failed: " + std::string(std::strerror(errno)); + __android_log_print(ANDROID_LOG_ERROR, kSpikeLogTag, "%s", result.message.c_str()); + return result; + } + } + result.waitStatus = waitStatus; + if (WIFEXITED(waitStatus)) { + result.exitCode = WEXITSTATUS(waitStatus); + } + if (WIFSIGNALED(waitStatus)) { + result.termSignal = WTERMSIG(waitStatus); + } + + result.markerContent = ReadWholeFile(request.markerPath); + result.childOutput = ReadWholeFile(childOutputPath); + result.succeeded = + result.execErrno == 0 && result.exitCode == 0 && !result.markerContent.empty(); + + std::ostringstream message; + message << "spike-spawn: " << (result.succeeded ? "OK" : "FAILED") + << " server=" << request.serverPath + << " pid=" << result.childPid + << " exit=" << result.exitCode + << " signal=" << result.termSignal + // Always printed, including on the success path, so a reader never has to + // guess whether the field was collected or merely absent. + << " execErrno=" << result.execErrno + << " (" << (result.execErrno == 0 ? "exec succeeded" + : std::strerror(result.execErrno)) << ")" + << " parentSelinux=" << result.parentSelinuxContext + << " marker=[" << result.markerContent << "]" + << " childStdout=[" << result.childOutput << "]"; + result.message = message.str(); + __android_log_print(result.succeeded ? ANDROID_LOG_INFO : ANDROID_LOG_ERROR, kSpikeLogTag, + "%s", result.message.c_str()); + + // The Activity is normally gone as soon as the run finishes, so the verdict also goes + // to a file next to the marker; that is what a device lane copies out. + std::ofstream report(request.markerPath + ".report", std::ios::trunc); + if (report) { + report << result.message << "\n"; + } + return result; +} + +} // namespace mobilegl_trace diff --git a/android-plugin/app/src/trace/cpp/spawn_spike.hpp b/android-plugin/app/src/trace/cpp/spawn_spike.hpp new file mode 100644 index 00000000..42426cbc --- /dev/null +++ b/android-plugin/app/src/trace/cpp/spawn_spike.hpp @@ -0,0 +1,60 @@ +#pragma once + +// --------------------------------------------------------------------------- +// P0 spike A: exec a second packaged native executable from this process. +// +// Answers one question and nothing else: can an ordinary Android application +// process (untrusted_app, NOT an `adb run-as` shell, which runs in a different +// SELinux domain and would prove nothing) exec a binary that was shipped inside +// its own APK as lib//lib*.so? The disaggregated design needs a server +// process on Android and this is its only supported delivery route (PLAN-B.md +// §8.1, inheriting PLAN.md §11.1-§11.6). +// +// This lives beside trace_replay_core.hpp rather than inside it because +// trace_replay_core.cpp is ALSO compiled by the desktop mobilegl_trace_replay +// runner (tools/trace_replay/CMakeLists.txt names it directly), where +// does not exist. The spike is Android-only, so it gets an Android-only TU; +// spawn_spike.cpp is listed only by the trace APK's CMakeLists. +// +// Nothing in the replay path calls this; it runs only when the trace Activity is +// launched with the `mobilegl_spike_spawn` intent extra. +// --------------------------------------------------------------------------- + +#include + +namespace mobilegl_trace { + +struct SpawnSpikeRequest { + // Absolute path of the executable, normally + // getApplicationInfo().nativeLibraryDir + "/libMobileGLServer.so". + std::string serverPath; + // Marker file the child is asked to write, passed to it as argv[1]. The child's + // stdout and stderr are captured next to it, with ".stdout" appended. + std::string markerPath; +}; + +struct SpawnSpikeResult { + bool spawned = false; + // Exec'd, waited for, exited 0, and the marker file came back non-empty. + bool succeeded = false; + // errno of the pre-fork or fork failure - the parent could not even try. + int spawnErrno = 0; + // errno of a REFUSED execve, carried out of the child over a close-on-exec pipe. + // This is the one datum the spike exists to produce: EACCES (SELinux or the mount's + // noexec) and ENOEXEC (the packager mangled the file) are different verdicts, and + // the exit status alone cannot tell them apart. + int execErrno = 0; + int childPid = -1; + int waitStatus = -1; + int exitCode = -1; + int termSignal = -1; + // /proc/self/attr/current of THIS process - the domain the exec was attempted from. + std::string parentSelinuxContext; + std::string markerContent; + std::string childOutput; + std::string message; +}; + +SpawnSpikeResult RunSpawnSpike(const SpawnSpikeRequest& request); + +} // namespace mobilegl_trace diff --git a/android-plugin/app/src/trace/cpp/trace_replay_jni.cpp b/android-plugin/app/src/trace/cpp/trace_replay_jni.cpp index f6d61b79..2f4b065c 100644 --- a/android-plugin/app/src/trace/cpp/trace_replay_jni.cpp +++ b/android-plugin/app/src/trace/cpp/trace_replay_jni.cpp @@ -1,5 +1,7 @@ #include "trace_replay_core.hpp" +#include "spawn_spike.hpp" + #include #include #include @@ -196,3 +198,20 @@ Java_top_mobilegl_plugin_trace_TraceReplayActivity_nativeRunTraceReplay(JNIEnv* } return MakeResult(env, result); } + +// P0 spike A: exec the packaged MobileGLServer stub from this app process and report what +// happened. Deliberately a separate entry point rather than another parameter on the +// replay call - it shares nothing with a replay, and the trace lane must be able to run +// it without a trace, a golden or a surface. +extern "C" JNIEXPORT jstring JNICALL +Java_top_mobilegl_plugin_trace_TraceReplayActivity_nativeRunSpawnSpike(JNIEnv* env, + jclass, + jstring serverPath, + jstring markerPath) { + mobilegl_trace::SpawnSpikeRequest request; + request.serverPath = ToString(env, serverPath); + request.markerPath = ToString(env, markerPath); + + const mobilegl_trace::SpawnSpikeResult result = mobilegl_trace::RunSpawnSpike(request); + return env->NewStringUTF(result.message.c_str()); +} diff --git a/android-plugin/app/src/trace/java/top/mobilegl/plugin/trace/TraceReplayActivity.java b/android-plugin/app/src/trace/java/top/mobilegl/plugin/trace/TraceReplayActivity.java index 86754130..9d07e23b 100644 --- a/android-plugin/app/src/trace/java/top/mobilegl/plugin/trace/TraceReplayActivity.java +++ b/android-plugin/app/src/trace/java/top/mobilegl/plugin/trace/TraceReplayActivity.java @@ -54,6 +54,14 @@ public final class TraceReplayActivity extends Activity { android.view.ViewGroup.LayoutParams.WRAP_CONTENT )); + // P0 spike A: when asked, exec the packaged server stub out of nativeLibraryDir + // instead of replaying anything. This mode needs no trace and no render surface. + String spikeLibrary = spawnSpikeLibrary(intent); + if (spikeLibrary != null) { + runSpawnSpike(spikeLibrary); + return; + } + SurfaceHolder holder = surfaceView.getHolder(); if (request.width > 0 && request.height > 0) { holder.setFixedSize(request.width, request.height); @@ -166,6 +174,53 @@ public final class TraceReplayActivity extends Activity { String benchmarkResultPath ); + + // --------------------------------------------------------------------------- + // P0 spike A: prove an APK can ship a second native executable and exec it. + // + // The exec has to happen here, in the application's own process: an `adb shell + // run-as` invocation runs in a different SELinux domain, so it can succeed while + // the real app is denied. The child reports the domain it ended up in, and the + // parent reports the domain it spawned from, so the log line stands on its own. + // --------------------------------------------------------------------------- + private static final String EXTRA_SPAWN_SPIKE = "mobilegl_spike_spawn"; + private static final String DEFAULT_SPAWN_SPIKE_LIBRARY = "libMobileGLServer.so"; + + private static String spawnSpikeLibrary(Intent intent) { + if (!intent.hasExtra(EXTRA_SPAWN_SPIKE)) { + return null; + } + // Accepts --ez (boolean, arrives as a null string) and --es with either a truthy + // marker or the library file name to exec. + String value = intent.getStringExtra(EXTRA_SPAWN_SPIKE); + if (value == null || value.isEmpty() || "1".equals(value) || "true".equals(value)) { + return DEFAULT_SPAWN_SPIKE_LIBRARY; + } + return value; + } + + private void runSpawnSpike(String libraryName) { + // The surface callbacks fire regardless; this keeps them from starting a replay + // underneath the spike. + started = true; + File outputDir = new File(request.outputDir); + String serverPath = new File(getApplicationInfo().nativeLibraryDir, libraryName) + .getAbsolutePath(); + String markerPath = new File(outputDir, "spike-spawn.txt").getAbsolutePath(); + statusView.setText("Running spawn spike\n" + serverPath); + new Thread(() -> { + outputDir.mkdirs(); + String message = nativeRunSpawnSpike(serverPath, markerPath); + Log.i(TAG, message); + runOnUiThread(() -> { + statusView.setText(message); + finish(); + }); + }, "MobileGLSpawnSpike").start(); + } + + private static native String nativeRunSpawnSpike(String serverPath, String markerPath); + private static final class TraceReplayRequest { final String tracePath; final String goldenPath; diff --git a/build.gradle b/build.gradle index c724fcd1..8552b9f8 100644 --- a/build.gradle +++ b/build.gradle @@ -5,6 +5,16 @@ def mobileGlLogActiveLevel = { } def standalonePluginBuild = rootProject.name == 'MobileGLPlugin' +// P0 spike A opt-in. OFF by default: the spike binary is dead weight in every trace APK +// that is not running the spike, and a flavour that silently carries an extra executable +// is exactly the kind of thing nobody notices until it ships. Turn it on for the spike +// build only, with either +// ./gradlew :app:assembleTraceDebug -Pmobilegl.buildServerSpike=ON +// or MOBILEGL_BUILD_SERVER_SPIKE=ON in the environment. +def mobileGlBuildServerSpike = { + (rootProject.findProperty('mobilegl.buildServerSpike') ?: System.getenv('MOBILEGL_BUILD_SERVER_SPIKE') ?: 'OFF') as String +} + android { namespace 'top.mobilegl.mobilegl' compileSdk 34 @@ -53,6 +63,13 @@ android { externalNativeBuild { cmake { arguments '-DMOBILEGL_TRACE_ANGLE_VARIANTS=ON' + // P0 spike A: the second native executable that proves an APK + // can ship one and exec it from nativeLibraryDir. Off unless + // asked for, and only offered here - the shipping plugin + // flavour cannot turn it on at all, so no released artifact can + // grow a binary nothing loads. See mobileGlBuildServerSpike + // above for the two ways to enable it. + arguments "-DMOBILEGL_BUILD_SERVER_SPIKE=${mobileGlBuildServerSpike()}" } } } diff --git a/tools/spikes/server_stub/main.cpp b/tools/spikes/server_stub/main.cpp new file mode 100644 index 00000000..15280d02 --- /dev/null +++ b/tools/spikes/server_stub/main.cpp @@ -0,0 +1,89 @@ +// P0 spike A - the Android delivery chain for a second native executable. +// +// The disaggregated design (PLAN-B.md §8.1, inheriting PLAN.md §11.1-§11.6) needs a +// second process on Android. Android has no exec-able install location an application +// can write to, so the only supported way to ship an executable inside an APK is to +// name it lib*.so, let the packager put it in lib// and exec it out of +// getApplicationInfo().nativeLibraryDir. Whether that actually works from the app's own +// untrusted_app SELinux domain - as opposed to from an adb `run-as` shell, which runs in +// a different domain and proves nothing - is the question this spike answers. +// +// This binary is deliberately the smallest thing that can answer it: it prints one line +// describing the process it ended up being (pid, uid, and its own SELinux context) to +// stdout and writes the same line to the file named by argv[1], then exits 0. The parent +// reads both back; see RunSpawnSpike() in +// android-plugin/app/src/trace/cpp/trace_replay_core.cpp. +// +// It is built only when MOBILEGL_BUILD_SERVER_SPIKE=ON on an ANDROID configure, so it is +// absent from every shipping build. It is not the future server, and nothing links it. + +#include +#include +#include + +#include + +namespace { + +// The single fact that makes this spike conclusive rather than suggestive: the exec'd +// child reports the domain it is running in. `u:r:untrusted_app:s0:...` means an ordinary +// application process really did exec this file; anything else (shell, adb, a platform +// domain) means the test was run the wrong way and its verdict does not transfer. +void ReadSelinuxContext(char* out, size_t size) { + out[0] = '\0'; + FILE* file = std::fopen("/proc/self/attr/current", "r"); + if (file == nullptr) { + std::snprintf(out, size, ""); + return; + } + const size_t read = std::fread(out, 1, size - 1, file); + std::fclose(file); + out[read] = '\0'; + // The kernel returns the context NUL-terminated inside the read; trim anything after. + for (size_t index = 0; index < read; ++index) { + if (out[index] == '\n' || out[index] == '\0') { + out[index] = '\0'; + break; + } + } + if (out[0] == '\0') { + std::snprintf(out, size, ""); + } +} + +} // namespace + +int main(int argc, char** argv) { + char context[256]; + ReadSelinuxContext(context, sizeof(context)); + + char line[1024]; + std::snprintf(line, sizeof(line), + "MobileGLServer-spike ok argv0=%s pid=%d ppid=%d uid=%d gid=%d argc=%d " + "selinux=%s\n", + argc > 0 && argv[0] != nullptr ? argv[0] : "", + static_cast(getpid()), static_cast(getppid()), + static_cast(getuid()), static_cast(getgid()), argc, context); + + std::fputs(line, stdout); + std::fflush(stdout); + + if (argc < 2 || argv[1] == nullptr || argv[1][0] == '\0') { + std::fputs("MobileGLServer-spike: argv[1] (marker path) missing\n", stderr); + return 2; + } + + FILE* marker = std::fopen(argv[1], "w"); + if (marker == nullptr) { + std::fprintf(stderr, "MobileGLServer-spike: cannot open marker %s: %s\n", argv[1], + std::strerror(errno)); + return 3; + } + std::fputs(line, marker); + if (std::fclose(marker) != 0) { + std::fprintf(stderr, "MobileGLServer-spike: cannot write marker %s: %s\n", argv[1], + std::strerror(errno)); + return 4; + } + return 0; +}