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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user