[Android] retain trace replay ownership across Activity recreation

This commit is contained in:
2026-09-20 10:32:18 -04:00
parent 10bfd29fc5
commit 7fd83133c0
10 changed files with 383 additions and 16 deletions
@@ -52,8 +52,11 @@
android:name="pojavEnv"
tools:node="remove" />
<!-- Keep the native render Surface through runtime overlay/display changes.
See the integer resource for the API 34-compatible configuration mask. -->
<activity
android:name=".trace.TraceReplayActivity"
android:configChanges="@integer/trace_replay_config_changes"
android:exported="true"
android:theme="@style/Theme.MobileGLPlugin">
<intent-filter>
@@ -2,6 +2,7 @@
#include "spawn_spike.hpp"
#include "trace_env_overrides.hpp"
#include "trace_replay_lease.hpp"
#include <android/native_window.h>
#include <android/native_window_jni.h>
@@ -119,6 +120,18 @@ Java_top_mobilegl_plugin_trace_TraceReplayActivity_nativeRunTraceReplay(JNIEnv*
jboolean benchmarkFinish,
jstring benchmarkResultPath,
jstring envOverrides) {
// Claim before changing GLWS globals, environment, logs or output files. The lease
// outlives replayState, so another invocation cannot race native-window cleanup.
mobilegl_trace::TraceReplayLease lease;
if (!lease) {
mobilegl_trace::Result rejected;
rejected.statusCode = mobilegl_trace::STATUS_RETRACE_FAILED;
rejected.message = "another trace replay is already running in this process";
// No paths: a duplicate Activity can share outputDir with the live invocation.
// Publishing this failure there would overwrite that invocation's real result.
return MakeResult(env, rejected);
}
mobilegl_trace::Request request;
request.tracePath = ToString(env, tracePath);
request.goldenPath = ToString(env, goldenPath);
@@ -157,12 +170,20 @@ Java_top_mobilegl_plugin_trace_TraceReplayActivity_nativeRunTraceReplay(JNIEnv*
request.benchmarkResultPath = ToString(env, benchmarkResultPath);
request.envOverrides = SplitSemicolonList(ToString(env, envOverrides));
ScopedTraceReplayState replayState;
mobilegl_trace_set_requested_size(request.width, request.height);
const bool needsNativeWindow =
request.backend == "DirectVulkan" ||
(request.backend == "DirectGLES" && !request.usePbuffer);
ANativeWindow *window = needsNativeWindow && surface != nullptr ? ANativeWindow_fromSurface(env, surface) : nullptr;
if (needsNativeWindow && window == nullptr) {
auto result = MakeFailureResult(request, mobilegl_trace::STATUS_RETRACE_FAILED,
"render surface was destroyed before native replay started");
if (!result.resultPath.empty()) {
mobilegl_trace::WriteResultJson(request, result);
}
return MakeResult(env, result);
}
ScopedTraceReplayState replayState;
mobilegl_trace_set_requested_size(request.width, request.height);
mobilegl_trace_set_native_window(window);
if (window != nullptr) {
ANativeWindow_release(window);
@@ -0,0 +1,28 @@
#pragma once
#include <atomic>
namespace mobilegl_trace {
// apitrace, its GLWS adapter and the environment are process-global. --singlethread
// controls trace dispatch only; it does not make two JNI invocations independent.
class TraceReplayLease {
public:
TraceReplayLease() : acquired_(!running_.test_and_set(std::memory_order_acquire)) {}
~TraceReplayLease() {
if (acquired_) {
running_.clear(std::memory_order_release);
}
}
TraceReplayLease(const TraceReplayLease&) = delete;
TraceReplayLease& operator=(const TraceReplayLease&) = delete;
explicit operator bool() const { return acquired_; }
private:
inline static std::atomic_flag running_ = ATOMIC_FLAG_INIT;
const bool acquired_;
};
} // namespace mobilegl_trace
@@ -25,7 +25,8 @@ public final class TraceReplayActivity extends Activity {
private TextView statusView;
private TraceReplayRequest request;
private boolean started;
private TraceReplaySession<TraceReplayResult> replaySession;
private final TraceReplaySession.Listener<TraceReplayResult> replayListener = this::onReplayComplete;
private final Handler mainHandler = new Handler(Looper.getMainLooper());
@Override
@@ -62,6 +63,18 @@ public final class TraceReplayActivity extends Activity {
return;
}
@SuppressWarnings("unchecked")
TraceReplaySession<TraceReplayResult> retained =
(TraceReplaySession<TraceReplayResult>) getLastNonConfigurationInstance();
replaySession = retained != null ? retained : new TraceReplaySession<>(
command -> new Thread(command, "MobileGLTraceReplay").start(),
command -> new Handler(Looper.getMainLooper()).post(command));
if (replaySession.hasStarted()) {
Log.i(TAG, "Reattaching to retained trace replay: " + request.outputDir);
statusView.setText("Running trace replay\n" + request.outputDir);
}
replaySession.attach(replayListener);
SurfaceHolder holder = surfaceView.getHolder();
if (request.width > 0 && request.height > 0) {
holder.setFixedSize(request.width, request.height);
@@ -87,20 +100,43 @@ public final class TraceReplayActivity extends Activity {
mainHandler.postDelayed(() -> startReplay(holder), 250);
}
@Override
public Object onRetainNonConfigurationInstance() {
return replaySession;
}
@Override
protected void onDestroy() {
// A callback queued by an old Surface must not start after that Activity is gone.
mainHandler.removeCallbacksAndMessages(null);
if (replaySession != null) {
replaySession.detach(replayListener);
}
super.onDestroy();
}
private void startReplay(SurfaceHolder holder) {
if (started) {
if (isFinishing() || isDestroyed() || replaySession.hasStarted()) {
return;
}
Surface surface = holder.getSurface();
if (surface == null || !surface.isValid()) {
return;
}
started = true;
statusView.setText("Running trace replay\n" + request.outputDir);
new Thread(() -> runRequest(request, surface), "MobileGLTraceReplay").start();
TraceReplayRequest replayRequest = request;
// The worker retains the request and Surface, not the Activity. JNI also acquires
// its own ANativeWindow reference until the replay and native cleanup finish.
replaySession.start(() -> runRequest(replayRequest, surface));
}
private void runRequest(TraceReplayRequest request, Surface surface) {
private void onReplayComplete(TraceReplayResult result) {
statusView.setText(result.toString());
finish();
}
private static TraceReplayResult runRequest(TraceReplayRequest request, Surface surface) {
Log.i(TAG, "Starting native trace replay: " + request.outputDir);
TraceReplayResult result = nativeRunTraceReplay(
surface,
request.tracePath,
@@ -135,11 +171,7 @@ public final class TraceReplayActivity extends Activity {
request.envOverrides
);
Log.i(TAG, result.toString());
TraceReplayResult finalResult = result;
runOnUiThread(() -> {
statusView.setText(finalResult.toString());
finish();
});
return result;
}
private static native TraceReplayResult nativeRunTraceReplay(
@@ -202,9 +234,7 @@ public final class TraceReplayActivity extends Activity {
}
private void runSpawnSpike(String libraryName) {
// The surface callbacks fire regardless; this keeps them from starting a replay
// underneath the spike.
started = true;
// onCreate returns before installing replay surface callbacks in this mode.
File outputDir = new File(request.outputDir);
String serverPath = new File(getApplicationInfo().nativeLibraryDir, libraryName)
.getAbsolutePath();
@@ -0,0 +1,59 @@
package top.mobilegl.plugin.trace;
import java.util.concurrent.Executor;
import java.util.function.Supplier;
/** One native invocation, retained when Android replaces the Activity that displays it. */
final class TraceReplaySession<T> {
interface Listener<T> {
void onComplete(T result);
}
private final Executor worker;
private final Executor completion;
private boolean started;
private boolean completed;
private T result;
private Listener<T> listener;
TraceReplaySession(Executor worker, Executor completion) {
this.worker = worker;
this.completion = completion;
}
// All session state is accessed on the main thread. Only replay.get() runs on the worker.
boolean hasStarted() {
return started;
}
boolean start(Supplier<T> replay) {
if (started) {
return false;
}
started = true;
worker.execute(() -> {
T replayResult = replay.get();
completion.execute(() -> {
result = replayResult;
completed = true;
if (listener != null) {
listener.onComplete(result);
}
});
});
return true;
}
void attach(Listener<T> newListener) {
listener = newListener;
if (completed) {
listener.onComplete(result);
}
}
void detach(Listener<T> oldListener) {
if (listener == oldListener) {
listener = null;
}
}
}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- CONFIG_ASSETS_PATHS (0x80000000) exists in Android 34, but that SDK has no
public configChanges flag name for it. Package parsing resolves this integer
through TypedArray.getInt. Keep it unqualified: overlay installation must not
recreate the Activity and destroy the live native render Surface.
0x0f80 = orientation | screenLayout | uiMode | screenSize | smallestScreenSize,
matching PluginActivity. Trace framebuffer dimensions are request parameters,
and these configuration changes need no new view or native replay. -->
<integer name="trace_replay_config_changes">0x80000f80</integer>
</resources>