fix(player): recover playback after a lost native-channel handoff

A player whose predecessor had not released the shared native channel
within three seconds skipped its own native dispose and chained its
release onto that predecessor. If the predecessor's teardown never
completed - one hung 4K session was enough - every later player waited
on the chain, failed to initialize, and playback stayed broken with
"Playback could not be started" until the app was killed.

Initialize and dispose now carry the creating instance's token. The
Android plugins remember which token created the current core and
acknowledge a dispose from any other token without touching the core,
so a dispose that lost the ownership race is provably stale and safe to
send. With that guard, a timed-out ownership wait force-disposes
instead of skipping, settles its release unchained, and frees the
channel for the next session; a dispose watchdog answers Dart even if
a native teardown hangs, leaking that one core instead of wedging all
future playback. Commands wait eight seconds (was three) so a slow but
healthy teardown delays the next session instead of failing it.

Verified on a Shield Pro: 38 back-to-back session races at 0.8-2.2s
gaps with zero failures and balanced teardowns, and a deep-link-over-
playback collision whose stale dispose is ignored, after which Back
returns to the still-playing session and Retry starts the new one.
Apple and desktop handlers ignore the token and keep the historical
skip semantics until they gain the guard.
This commit is contained in:
edde746
2026-08-26 18:59:28 +02:00
parent 3c6d398598
commit 3f03262833
7 changed files with 258 additions and 22 deletions
@@ -38,6 +38,11 @@ class ExoPlayerPlugin :
private val mainHandler get() = channels.mainHandler
private fun runOnMain(block: () -> Unit) = channels.runOnMain(block)
private var playerCore: ExoPlayerCore? = null
// The Dart instanceId that created the current core. A `dispose` carrying a
// different token lost the ownership race to a successor and must not tear
// down that successor's session; it is acknowledged without touching it.
private var coreInstanceId: Long? = null
private var mpvCore: MpvPlayerCore? = null // MPV fallback player
private var usingMpvFallback: Boolean = false
private var fallbackInProgress: Boolean = false
@@ -161,6 +166,7 @@ class ExoPlayerPlugin :
val exoCore = playerCore
val fallbackCore = mpvCore
playerCore = null
coreInstanceId = null
mpvCore = null
usingMpvFallback = false
fallbackInProgress = false
@@ -228,7 +234,7 @@ class ExoPlayerPlugin :
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"initialize" -> handleInitialize(call, result)
"dispose" -> handleDispose(result)
"dispose" -> handleDispose(call, result)
"open" -> handleOpen(call, result)
"play" -> handlePlay(result)
"pause" -> handlePause(result)
@@ -337,6 +343,7 @@ class ExoPlayerPlugin :
this.debugLoggingEnabled = this@ExoPlayerPlugin.debugLoggingEnabled
}
playerCore = core
coreInstanceId = call.argument<Number>("instanceId")?.toLong()
val success = core.initialize(
tunnelingEnabled = tunnelingEnabled,
audioPassthroughEnabled = audioPassthroughEnabled,
@@ -365,8 +372,15 @@ class ExoPlayerPlugin :
}
}
private fun handleDispose(result: MethodChannel.Result) {
private fun handleDispose(call: MethodCall, result: MethodChannel.Result) {
val token = call.argument<Number>("instanceId")?.toLong()
runOnMain {
val owner = coreInstanceId
if ((playerCore != null || mpvCore != null) && token != null && owner != null && token != owner) {
Log.d(TAG, "Ignoring stale dispose (token=$token, core owner=$owner)")
result.success(null)
return@runOnMain
}
teardownSession(clearActivity = false)
Log.d(TAG, "Disposed")
result.success(null)
@@ -15,6 +15,7 @@ import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.CancellationException
import java.util.concurrent.atomic.AtomicBoolean
internal fun completeMpvPropertyResult(
result: MethodChannel.Result,
@@ -63,6 +64,17 @@ open class MpvPlayerPlugin(
private val nameToId = mutableMapOf<String, Int>()
private var sessionGeneration = 0
// The Dart instanceId that created the current core. A `dispose` carrying a
// different token lost the ownership race to a successor and must not tear
// down that successor's core; it is acknowledged without touching anything.
private var coreInstanceId: Long? = null
// How long a Dart `dispose` waits for the native teardown before being
// answered anyway. Generous against slow-but-healthy teardowns (a 4K HDR
// session's surface/audio release); small against the alternative, which
// is wedging every subsequent playback session behind a hung teardown.
private val disposeWatchdogMs = 6_000L
/** Same semantics as Activity.runOnUiThread, without needing an Activity. */
private fun runOnMain(block: () -> Unit) = channels.runOnMain(block)
@@ -96,6 +108,7 @@ open class MpvPlayerPlugin(
++sessionGeneration
val core = playerCore
playerCore = null
coreInstanceId = null
cancelPendingInits()
return core
}
@@ -150,7 +163,7 @@ open class MpvPlayerPlugin(
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"initialize" -> handleInitialize(call, result)
"dispose" -> handleDispose(result)
"dispose" -> handleDispose(call, result)
"setProperty" -> handleSetProperty(call, result)
"getProperty" -> handleGetProperty(call, result)
"getStats" -> handleGetStats(result)
@@ -255,6 +268,7 @@ open class MpvPlayerPlugin(
delegate = this@MpvPlayerPlugin
}
playerCore = core
coreInstanceId = call.argument<Number>("instanceId")?.toLong()
} catch (e: Exception) {
Log.e(tag, "Failed to initialize: ${e.message}", e)
completePendingInits(attempt, success = false, errorMessage = e.message)
@@ -266,7 +280,10 @@ open class MpvPlayerPlugin(
playerCore !== core ||
!isCurrentInitAttempt(attempt)
if (stale || !success) {
if (playerCore === core) playerCore = null
if (playerCore === core) {
playerCore = null
coreInstanceId = null
}
core.dispose()
if (stale) {
Log.d(tag, "Stale init callback (gen=$gen, current=$sessionGeneration)")
@@ -323,13 +340,37 @@ open class MpvPlayerPlugin(
}
}
private fun handleDispose(result: MethodChannel.Result) {
private fun handleDispose(call: MethodCall, result: MethodChannel.Result) {
val token = call.argument<Number>("instanceId")?.toLong()
runOnMain {
val core = takeCoreForTeardown()
core?.dispose {
Log.d(tag, "Disposed")
val owner = coreInstanceId
if (playerCore != null && token != null && owner != null && token != owner) {
// This dispose lost the ownership race: a successor already created
// the current core. Acknowledge without touching it.
Log.d(tag, "Ignoring stale dispose (token=$token, core owner=$owner)")
result.success(null)
} ?: result.success(null)
return@runOnMain
}
val core = takeCoreForTeardown()
if (core == null) {
result.success(null)
return@runOnMain
}
// A hung native teardown must not wedge the Dart-side release chain:
// answer after the watchdog even if the teardown thread is stuck, so
// the next session can start on a fresh core. The stuck core leaks its
// resources until the process ends — recoverable, unlike the wedge.
val completed = AtomicBoolean(false)
fun completeOnce(reason: String) {
if (completed.compareAndSet(false, true)) {
Log.d(tag, reason)
result.success(null)
}
}
channels.mainHandler.postDelayed({
completeOnce("Dispose watchdog fired after ${disposeWatchdogMs}ms; teardown continues in background")
}, disposeWatchdogMs)
core.dispose { completeOnce("Disposed") }
}
}
@@ -24,6 +24,7 @@ import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.suspendCancellableCoroutine
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -632,6 +633,53 @@ class MpvPlayerPluginTest {
assertEquals(0, pending.size)
}
@Test
fun staleDisposeIsAcknowledgedWithoutTearingDownTheCore() {
// A dispose whose instanceId is not the core creator's lost the ownership
// race to a successor; tearing the core down anyway would kill that
// successor's session. It must be acknowledged as a no-op instead.
val plugin = MpvPlayerPlugin()
installCore(plugin, testCore { _, _ -> })
setPluginField(plugin, "coreInstanceId", 2L)
val result = RecordingResult()
plugin.onMethodCall(MethodCall("dispose", mapOf("instanceId" to 1)), result)
awaitCompletion(result)
assertNull(result.errorCode)
assertNotNull(getPluginField(plugin, "playerCore"))
assertEquals(2L, getPluginField(plugin, "coreInstanceId"))
}
@Test
fun matchingDisposeTearsDownTheCoreAndClearsTheToken() {
val plugin = MpvPlayerPlugin()
installCore(plugin, testCore { _, _ -> })
setPluginField(plugin, "coreInstanceId", 7L)
val result = RecordingResult()
plugin.onMethodCall(MethodCall("dispose", mapOf("instanceId" to 7)), result)
awaitCompletion(result)
assertNull(result.errorCode)
assertNull(getPluginField(plugin, "playerCore"))
assertNull(getPluginField(plugin, "coreInstanceId"))
}
@Test
fun tokenlessDisposeKeepsLegacySemanticsAndTearsDownTheCore() {
val plugin = MpvPlayerPlugin()
installCore(plugin, testCore { _, _ -> })
setPluginField(plugin, "coreInstanceId", 7L)
val result = RecordingResult()
plugin.onMethodCall(MethodCall("dispose", null), result)
awaitCompletion(result)
assertNull(result.errorCode)
assertNull(getPluginField(plugin, "playerCore"))
}
@Test
fun configDetachThenEngineDetachTearsDownVideoCoreAndPendingInitOnce() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()