fix(android): retire mpv sessions without blocking hook callbacks

This commit is contained in:
edde746
2026-09-06 19:11:25 +02:00
parent 908ccbb233
commit 99f4fda55a
9 changed files with 648 additions and 36 deletions
+2 -1
View File
@@ -11,7 +11,8 @@
// starts (event_thread_bind). Kotlin drops a callback whose session is not
// the wrapper it published for, so a retiring core's tail — end-file,
// property changes, a hook raised just before teardown — can never be read
// as the successor's.
// as the successor's. L keeps this binding immutable through join, including
// the interval after admission is revoked; callbacks never acquire L.
static mpv_handle* thread_mpv;
static uint64_t thread_session;
+4 -4
View File
@@ -4,10 +4,10 @@
#include <cstdint>
// Names the session the next event_thread serves. Called under the lifecycle
// write lock before pthread_create, which publishes the values to the thread;
// the thread then never reads the globals, so a successor session replacing
// them cannot redirect it.
// Names the session the next event_thread serves. Called under L and S before
// pthread_create publishes the binding. L prevents rebinding until that thread
// is joined; the thread never reads the live admission slots and can safely
// finish a callback after revocation. Callbacks must never acquire L.
void event_thread_bind(mpv_handle* mpv, uint64_t session);
void* event_thread(void* arg);
+8 -4
View File
@@ -15,10 +15,14 @@ extern JavaVM* g_vm;
// session it originated from, so a retired wrapper can neither touch nor be
// fed by its successor.
//
// g_session_lock: write-held across create/init/destroy, read-held for every
// other entry. A reader admitted under a matching session keeps the handle
// alive until it returns; retirement waits for it, then flips g_session so
// later callers with the old id are refused before they reach mpv.
// g_session_lock (S) guards admission: write-held for publication, init and
// revocation, read-held by every other JNI entry through its last handle use.
// Retirement drains admitted readers and clears these slots under S, then
// releases S BEFORE wake/join/terminate so callbacks can reenter and reject.
// A separate lifecycle mutex (L, private to main.cpp) serializes create/init/
// destroy through termination AND surface cleanup. Lock order is L -> S;
// readers and event callbacks never acquire L. The event thread borrows its
// immutable bound handle until joined, even after the admission slots clear.
extern mpv_handle* g_mpv;
extern uint64_t g_session;
extern pthread_rwlock_t g_session_lock;
+53 -25
View File
@@ -41,20 +41,33 @@ uint64_t g_session;
pthread_rwlock_t g_session_lock = PTHREAD_RWLOCK_INITIALIZER;
std::atomic<bool> g_event_thread_request_exit(false);
// Lifecycle serialization (L) is separate from session admission (S).
// Only nativeCreate/nativeInit/nativeDestroy acquire L, always before S.
static pthread_mutex_t lifecycle_lock = PTHREAD_MUTEX_INITIALIZER;
static uint64_t next_session = 0;
static pthread_t event_thread_id;
static bool event_thread_started = false;
// Write-locked scope for the lifecycle entries; waits for every admitted
// reader to return before the handle can change underneath it.
// Held through retirement, termination and surface cleanup, so a successor
// cannot overlap or rebind the retiring event thread. Callbacks never take L.
class LifecycleGuard {
public:
LifecycleGuard() { pthread_rwlock_wrlock(&g_session_lock); }
~LifecycleGuard() { pthread_rwlock_unlock(&g_session_lock); }
LifecycleGuard() { pthread_mutex_lock(&lifecycle_lock); }
~LifecycleGuard() { pthread_mutex_unlock(&lifecycle_lock); }
LifecycleGuard(const LifecycleGuard&) = delete;
LifecycleGuard& operator=(const LifecycleGuard&) = delete;
};
// Bounded admission-write scope (S), always nested inside L. Never hold this
// while joining or terminating: an event callback can reenter SessionGuard.
class SessionWriteGuard {
public:
SessionWriteGuard() { pthread_rwlock_wrlock(&g_session_lock); }
~SessionWriteGuard() { pthread_rwlock_unlock(&g_session_lock); }
SessionWriteGuard(const SessionWriteGuard&) = delete;
SessionWriteGuard& operator=(const SessionWriteGuard&) = delete;
};
static void prepare_environment(JNIEnv* env, jobject appctx) {
setlocale(LC_NUMERIC, "C");
@@ -66,25 +79,27 @@ static void prepare_environment(JNIEnv* env, jobject appctx) {
init_methods_cache(env);
}
// Caller holds the write lock. Stops the event thread, retires the session
// id (so a late nativeHookContinue / property call for it is refused, not
// delivered to whatever comes next), and tears the core down. mpv itself
// continues any hook the destroyed handle was still holding.
static void destroy_locked(JNIEnv* env) {
// Caller holds L and S(write). Acquiring S drained every admitted JNI reader;
// revoke further admission before letting an in-flight callback finish.
// The lifecycle owner retains the handle and immutable event-thread binding.
static mpv_handle* revoke_locked() {
mpv_handle* local_mpv = g_mpv;
g_mpv = NULL;
g_session = 0;
if (event_thread_started) g_event_thread_request_exit = true;
return local_mpv;
}
// Caller holds L, but NOT S. The event thread is the revoked handle's only
// remaining borrower; a rejected hook can take S and return during this join.
static void destroy_locked(JNIEnv* env, mpv_handle* local_mpv) {
// Configuration (including an invalid initial log level) can fail before
// nativeInit starts the event thread.
if (event_thread_started) {
g_event_thread_request_exit = true;
mpv_wakeup(local_mpv);
pthread_join(event_thread_id, NULL);
event_thread_started = false;
}
g_mpv = NULL;
g_session = 0;
// The MediaCodec VO can retain the Surface until final decoder teardown.
// Keep its JNI refs alive for the entire blocking termination.
mpv_terminate_destroy(local_mpv);
@@ -93,13 +108,20 @@ static void destroy_locked(JNIEnv* env) {
jni_func(jlong, nativeCreate, jobject appctx) {
LifecycleGuard lock;
prepare_environment(env, appctx);
if (g_mpv) {
mpv_handle* predecessor;
{
SessionWriteGuard admission;
predecessor = revoke_locked();
}
if (predecessor) {
ALOGE("destroying leaked mpv instance");
destroy_locked(env);
destroy_locked(env, predecessor);
}
// Do not rewrite process-wide JNI state while the predecessor can callback.
prepare_environment(env, appctx);
SessionWriteGuard admission;
g_mpv = mpv_create();
if (!g_mpv) {
die("context init failed");
@@ -113,6 +135,7 @@ jni_func(jlong, nativeCreate, jobject appctx) {
jni_func(jint, nativeInit, jlong session) {
LifecycleGuard lock;
SessionWriteGuard admission;
if (!g_mpv || g_session != (uint64_t)session) return MPV_ERROR_UNINITIALIZED;
const int result = mpv_initialize(g_mpv);
@@ -140,10 +163,15 @@ jni_func(jint, nativeInit, jlong session) {
jni_func(void, nativeDestroy, jlong session) {
LifecycleGuard lock;
// A wrapper whose session a later nativeCreate already retired has nothing
// left to destroy; the successor is not its to touch.
if (!g_mpv || g_session != (uint64_t)session) return;
destroy_locked(env);
mpv_handle* local_mpv;
{
SessionWriteGuard admission;
// A wrapper whose session a later nativeCreate already retired has nothing
// left to destroy; the successor is not its to touch.
if (!g_mpv || g_session != (uint64_t)session) return;
local_mpv = revoke_locked();
}
destroy_locked(env, local_mpv);
}
jni_func(jint, nativeSetLogLevel, jlong session, jstring jlevel) {
@@ -202,9 +230,9 @@ jni_func(jlong, nativeCommand, jlong session, jobjectArray jarray) {
return playlist_entry_id;
}
// A continuation for a retired session is dropped: the hook id belongs to a
// handle that no longer exists, and destroying that handle already continued
// it. The successor must never receive it.
// A continuation for a revoked session is dropped, even while its handle is
// still retiring. Destruction releases any outstanding hooks; the successor
// must never receive an old hook id.
jni_func(void, nativeHookContinue, jlong session, jlong id) {
SessionGuard guard(session);
if (!guard.mpv) return;
+3 -2
View File
@@ -70,8 +70,9 @@ jni_func(void, nativeDetachOsdSurface, jlong session) {
osd_surface = NULL;
}
// Caller holds the lifecycle write lock; the surfaces belong to the session
// being retired and are released with it.
// Caller holds L after revoking admission, draining JNI readers, joining the
// event thread and terminating mpv. S is not held. L prevents a successor from
// publishing new surfaces until these retiring references have been released.
void render_cleanup(JNIEnv* env) {
if (surface) {
env->DeleteGlobalRef(surface);