[MG_Remote, android-plugin] (P6): the device can run the split retrace, and only the app can spell the server's path

Two small pieces, so an Android spawn arm is a flag rather than a project.

THE RUNNER GAINS --transport. It already had a generic env passthrough - one
intent extra carries `K=V;K=V` all the way to setenv before the library is
dlopened - so the transport itself costs one entry and no new plumbing in the
five files between here and the device. monolith passes NOTHING rather than
MOBILEGL_TRANSPORT=monolith, so the default arm's environment stays
byte-identical to what it was before this option existed: a control that
changed when the option landed would not be one.

THE SERVER PATH CANNOT COME FROM THE HOST. Every other knob can, but this one
names a file that must be EXEC-ABLE, and on Android there is exactly one such
place: an APK's lib/<abi>/. W^X has forbidden the app's own data dirs since
API 29, and the packager only puts a file in lib/<abi>/ if it is named
lib*.so - which is why the server executable wears that name, and what P0
spike A proved on two devices. nativeLibraryDir contains an install-time
hash, so no host-side script can spell the path; the process about to launch
the server is the only one that can. TraceReplayActivity resolves it, and
only when the caller actually asked for spawn.

AN EXPLICIT MOBILEGL_IPC_SERVER_PATH STILL WINS. A caller that set one is
pointing somewhere deliberately - at a pushed build, say - and silently
replacing it would make that knob untestable on the one platform where it
matters most.

The APK must be built with -Pmobilegl.buildDisaggregated=ON or none of this
is reachable: without it ConfigLoader has no transport parser, accepts the
value, ignores it (CONTRACT-P5 rule 5), and the run is monolith under a name
that says spawn. The --transport help says so where somebody will read it.
This commit is contained in:
2026-09-21 12:34:34 -04:00
parent dc5bd935de
commit 534067493c
2 changed files with 59 additions and 4 deletions
@@ -45,7 +45,10 @@ public final class TraceReplayActivity extends Activity {
request = TraceReplayRequest.from(
intent,
getFilesDir(),
getString(top.mobilegl.plugin.R.string.mobilegl_default_backend)
getString(top.mobilegl.plugin.R.string.mobilegl_default_backend),
// P6: the ONE place that knows where an exec-able file lives. See
// resolveSpawnServerPath.
getApplicationInfo().nativeLibraryDir
);
statusView = new TextView(this);
statusView.setText("Waiting for render surface\n" + request.outputDir);
@@ -354,7 +357,37 @@ public final class TraceReplayActivity extends Activity {
this.envOverrides = envOverrides;
}
static TraceReplayRequest from(Intent intent, File filesDir, String defaultBackend) {
// P6: MOBILEGL_TRANSPORT=spawn NEEDS AN ABSOLUTE PATH TO AN EXEC-ABLE FILE, and on
// Android there is exactly one such place. An APK's lib/<abi>/ is the only directory an
// untrusted_app may exec from - W^X has forbidden the app's own data dirs since API 29 -
// and the packager only puts a file there if it is named lib*.so, which is why the server
// executable wears that name (P0 spike A proved the arrangement on two devices).
//
// NOTHING OFF THE DEVICE CAN SPELL THAT PATH. nativeLibraryDir contains an
// install-time hash, so the host-side runner cannot pass MOBILEGL_IPC_SERVER_PATH the way
// it passes every other knob; the path has to be resolved HERE, by the process that is
// about to launch the server. Left unresolved, LaunchServer refuses BY NAME and the arm
// reds on "unresolvable image" - honest, but about the wrong thing.
//
// AN EXPLICIT VALUE WINS. A caller that set MOBILEGL_IPC_SERVER_PATH itself is pointing
// somewhere deliberately, and silently replacing it would make that knob untestable.
static String resolveSpawnServerPath(String envOverrides, String nativeLibraryDir) {
if (nativeLibraryDir == null || nativeLibraryDir.isEmpty()) {
return envOverrides;
}
if (!envOverrides.contains("MOBILEGL_TRANSPORT=spawn")) {
return envOverrides;
}
if (envOverrides.contains("MOBILEGL_IPC_SERVER_PATH=")) {
return envOverrides;
}
String serverPath = new File(nativeLibraryDir, "libMobileGLServer.so").getAbsolutePath();
String entry = "MOBILEGL_IPC_SERVER_PATH=" + serverPath;
return envOverrides.isEmpty() ? entry : envOverrides + ";" + entry;
}
static TraceReplayRequest from(Intent intent, File filesDir, String defaultBackend,
String nativeLibraryDir) {
String outputDir = readString(intent, "output_dir", new File(filesDir, "trace-replay").getAbsolutePath());
String diffPath = readString(intent, "diff_path", "");
String benchmarkResultPath =
@@ -389,7 +422,7 @@ public final class TraceReplayActivity extends Activity {
intent.getIntExtra("benchmark_tail_frames", 200),
intent.getBooleanExtra("benchmark_finish", true),
benchmarkResultPath,
readString(intent, "mobilegl_env", "")
resolveSpawnServerPath(readString(intent, "mobilegl_env", ""), nativeLibraryDir)
);
}
@@ -403,6 +403,18 @@ def parse_args():
help="Do not glFinish at every frame boundary, so frame times measure CPU submission "
"only instead of GPU completion.",
)
parser.add_argument(
"--transport",
choices=("monolith", "inproc", "spawn"),
default="monolith",
help="MOBILEGL_TRANSPORT for the replay. `spawn` runs the server role in a SECOND PROCESS "
"on the device, launched out of the APK's lib/<abi>/ - which requires an APK built "
"with -Pmobilegl.buildDisaggregated=ON, or ConfigLoader has no parser, accepts the "
"value and ignores it (CONTRACT-P5 rule 5) and the run is monolith under a name that "
"says otherwise. MOBILEGL_IPC_SERVER_PATH is NOT passed from here: nativeLibraryDir "
"carries an install-time hash, so only the app can spell it, and "
"TraceReplayActivity.resolveSpawnServerPath does.",
)
parser.add_argument(
"--benchmark-timeout-seconds",
type=int,
@@ -412,6 +424,16 @@ def parse_args():
return parser.parse_args()
def transport_env(args):
"""The transport knob as env overrides, or nothing at all for monolith.
An EMPTY list on monolith rather than MOBILEGL_TRANSPORT=monolith, so the default arm's
environment is byte-identical to what it was before this option existed - the control arm has
to stay a control.
"""
return [] if args.transport == "monolith" else [f"MOBILEGL_TRANSPORT={args.transport}"]
def main():
args = parse_args()
selected_backends = args.backend or list(BACKENDS)
@@ -435,7 +457,7 @@ def main():
failures += run_benchmark_case(case, backend, args)
continue
print(f"=== Android retrace: {case['name']} / {backend} ===", flush=True)
rc = run_case(case, backend, env_overrides=args.env)
rc = run_case(case, backend, env_overrides=transport_env(args) + list(args.env))
try:
render_summary()
except Exception as error: