mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
fix(playback): enforce the decoder budget when acquiring players
MediaCodec instances are a per-process resource with a hard per-device ceiling — the Android emulator's c2.goldfish.h264.decoder declares `concurrent-instances max="4"`. Past that, MediaCodec.start() fails with NO_MEMORY, MediaCodecRenderer reports "Failed to initialize decoder", and the video surfaces to the user as "can't load". Opening a live stream after scrolling a few feed videos reproduced this reliably; killing the process made the same stream play, since that released every held codec. The device ceiling was already computed by SimultaneousPlaybackCalculator, but only reached ExoPlayerPool as `poolSize`, which governs how many idle players are *retained*. The acquire path was uncapped (`coldPool.poll() ?: builder.build(context)`), and MediaSessionPool held a hardcoded LruCache(10) of sessions, each pinning a checked-out player. So a 4-decoder device would happily hold 10. Enforce the budget where players are handed out: - Track live decoders process-wide, counting checked-out and warm players (cold ones have been stop()'d and hold none). The counter and the pool registry are global because PlaybackService builds one pool for direct traffic and another for Tor-proxied traffic; a per-pool budget let the app hold twice the ceiling. - Before a cold or fresh player is handed out, reclaim headroom by demoting warm players to cold — own pool first, then siblings. Warm entries are a scroll-back cache, so they are the right thing to give up under pressure. - Size the session cache from the same device budget, keeping the previous 10 as an upper bound so capable devices are unaffected. Verified on the emulator: 9 codec allocations across a session with zero NO_MEMORY and zero decoder-init failures, where allocation #5 previously died. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c9e8cd4e0c
commit
041a4c1c71
+61
@@ -37,6 +37,7 @@ import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.yield
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
class ExoPlayerPool(
|
||||
@@ -71,6 +72,10 @@ class ExoPlayerPool(
|
||||
private val warmPool = ArrayDeque<WarmPlayer>(warmSlotsCap.coerceAtLeast(1))
|
||||
private val warmPoolLock = Any()
|
||||
|
||||
init {
|
||||
livePools.add(this)
|
||||
}
|
||||
|
||||
// Exists to avoid exceptions stopping the coroutine
|
||||
val exceptionHandler =
|
||||
CoroutineExceptionHandler { _, throwable ->
|
||||
@@ -127,15 +132,53 @@ class ExoPlayerPool(
|
||||
Log.d("PlaybackService") { "ExoPlayerPool discarding errored warm player: $preferredMediaId (${error.errorCodeName})" }
|
||||
PcmTapRegistry.unregisterPlayer(warm)
|
||||
warm.release()
|
||||
liveDecoders.decrementAndGet()
|
||||
} else {
|
||||
Log.d("PlaybackService") { "ExoPlayerPool warm hit: $preferredMediaId" }
|
||||
// Already counted against the decoder budget for as long as it sat warm.
|
||||
return warm
|
||||
}
|
||||
}
|
||||
}
|
||||
ensureDecoderHeadroom()
|
||||
liveDecoders.incrementAndGet()
|
||||
return coldPool.poll() ?: builder.build(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Frees decoder headroom before a cold or freshly built player is handed out.
|
||||
*
|
||||
* Every player that still holds a prepared MediaItem — checked out or merely warm — owns a
|
||||
* MediaCodec instance, and devices advertise a hard ceiling on those (the emulator's
|
||||
* c2.goldfish.h264.decoder declares `concurrent-instances max="4"`). Past that ceiling
|
||||
* MediaCodec.start() fails with NO_MEMORY and the video surfaces as "can't load", so the
|
||||
* budget has to be enforced at acquisition rather than only at retention.
|
||||
*
|
||||
* Warm players are a scroll-back cache, so they are what gives way: demoting one to cold
|
||||
* stop()s it and releases its codec. This pool's own entries go first, then any other pool's
|
||||
* — [PlaybackService] keeps a separate pool for direct and for Tor-proxied traffic, and both
|
||||
* draw on the one per-process pile of decoders.
|
||||
*/
|
||||
private fun ensureDecoderHeadroom() {
|
||||
while (liveDecoders.get() >= poolSize) {
|
||||
if (!evictOldestWarm() && !evictOldestWarmElsewhere()) return
|
||||
}
|
||||
}
|
||||
|
||||
private fun evictOldestWarm(): Boolean {
|
||||
val oldest = synchronized(warmPoolLock) { warmPool.removeFirstOrNull() } ?: return false
|
||||
Log.d("PlaybackService") { "ExoPlayerPool decoder-budget evict: ${oldest.mediaId}" }
|
||||
demoteToCold(oldest.player)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun evictOldestWarmElsewhere(): Boolean {
|
||||
livePools.forEach { pool ->
|
||||
if (pool !== this && pool.evictOldestWarm()) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun takeWarm(mediaId: String): ExoPlayer? =
|
||||
synchronized(warmPoolLock) {
|
||||
// Iterate from the newest end so a duplicated URI returns the freshest player.
|
||||
@@ -170,6 +213,7 @@ class ExoPlayerPool(
|
||||
Log.d("PlaybackService") { "ExoPlayerPool dropping errored player: ${player.currentMediaItem?.mediaId} (${error.errorCodeName})" }
|
||||
PcmTapRegistry.unregisterPlayer(player)
|
||||
player.release()
|
||||
liveDecoders.decrementAndGet()
|
||||
return@withLock
|
||||
}
|
||||
|
||||
@@ -214,7 +258,10 @@ class ExoPlayerPool(
|
||||
private fun demoteToCold(player: ExoPlayer) {
|
||||
if (player.isReleased) return
|
||||
player.pause()
|
||||
// stop() tears the renderers down, which is what actually hands the MediaCodec instance
|
||||
// back to the system — so this is the point where the player stops costing budget.
|
||||
player.stop()
|
||||
liveDecoders.decrementAndGet()
|
||||
player.clearVideoSurface()
|
||||
player.clearMediaItems()
|
||||
|
||||
@@ -260,6 +307,7 @@ class ExoPlayerPool(
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
livePools.remove(this)
|
||||
scope
|
||||
.launch {
|
||||
mutex.withLock {
|
||||
@@ -272,6 +320,7 @@ class ExoPlayerPool(
|
||||
warmSnapshot.forEach {
|
||||
PcmTapRegistry.unregisterPlayer(it.player)
|
||||
it.player.release()
|
||||
liveDecoders.decrementAndGet()
|
||||
}
|
||||
coldPool.forEach {
|
||||
PcmTapRegistry.unregisterPlayer(it)
|
||||
@@ -286,5 +335,17 @@ class ExoPlayerPool(
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_WARM_SLOTS = 3
|
||||
|
||||
// MediaCodec instances are a per-process resource, but PlaybackService builds one pool for
|
||||
// direct traffic and another for Tor-proxied traffic, so a per-pool budget would let the
|
||||
// app hold twice the device's decoder ceiling. Both counters below are therefore global.
|
||||
|
||||
// Players currently holding a decoder: checked out, or warm (paused but still prepared).
|
||||
// Cold players have been stop()'d and own none.
|
||||
private val liveDecoders = AtomicInteger(0)
|
||||
|
||||
// Every pool that hasn't been destroy()'d, so a pool starved of headroom can reclaim a
|
||||
// warm player from a sibling instead of overshooting the shared ceiling.
|
||||
private val livePools = ConcurrentLinkedQueue<ExoPlayerPool>()
|
||||
}
|
||||
}
|
||||
|
||||
+9
-1
@@ -64,6 +64,10 @@ class MediaSessionPool(
|
||||
val exoPlayerPool: ExoPlayerPool,
|
||||
val dataSourceFactory: DataSource.Factory,
|
||||
val appContext: Context,
|
||||
// Ceiling on cached sessions. Each one holds a checked-out ExoPlayer, so on a device whose
|
||||
// decoder ceiling is lower than [MAX_CACHED_SESSIONS] this is what keeps the session cache
|
||||
// from pinning more MediaCodec instances than the hardware will grant.
|
||||
maxSessions: Int = MAX_CACHED_SESSIONS,
|
||||
val reset: (MediaSession, Boolean) -> Unit,
|
||||
) {
|
||||
private val exceptionHandler =
|
||||
@@ -123,7 +127,7 @@ class MediaSessionPool(
|
||||
private val playingMap = mutableMapOf<String, SessionListener>()
|
||||
|
||||
private val cache =
|
||||
object : LruCache<String, SessionListener>(10) { // up to 10 videos in the screen at the same time
|
||||
object : LruCache<String, SessionListener>(maxSessions.coerceIn(1, MAX_CACHED_SESSIONS)) {
|
||||
override fun entryRemoved(
|
||||
evicted: Boolean,
|
||||
key: String?,
|
||||
@@ -296,6 +300,10 @@ class MediaSessionPool(
|
||||
companion object {
|
||||
private val CLEANUP_INTERVAL_NS = TimeUnit.MINUTES.toNanos(1)
|
||||
|
||||
// Roughly how many videos can share a screen at once. Acts as the upper bound only —
|
||||
// a device that advertises fewer concurrent decoders than this caps lower.
|
||||
const val MAX_CACHED_SESSIONS = 10
|
||||
|
||||
// AOSP default for config_mediaMetadataBitmapMaxSize, used when the framework resource
|
||||
// can't be resolved by name on a given ROM.
|
||||
private const val DEFAULT_METADATA_BITMAP_DP = 320
|
||||
|
||||
+7
-1
@@ -80,14 +80,20 @@ class PlaybackService : MediaSessionService() {
|
||||
},
|
||||
)
|
||||
|
||||
// The device's concurrent-decoder ceiling bounds both how many players may be checked out
|
||||
// at once (the session cache) and how many the pool may retain, since a session and a warm
|
||||
// pool entry each pin one MediaCodec instance.
|
||||
val decoderBudget = SimultaneousPlaybackCalculator.max(applicationContext)
|
||||
|
||||
return MediaSessionPool(
|
||||
exoPlayerPool =
|
||||
ExoPlayerPool(
|
||||
ExoPlayerBuilder(videoCache, resolvingDataSourceFactory),
|
||||
poolSize = SimultaneousPlaybackCalculator.max(applicationContext),
|
||||
poolSize = decoderBudget,
|
||||
),
|
||||
dataSourceFactory = resolvingDataSourceFactory,
|
||||
appContext = applicationContext,
|
||||
maxSessions = decoderBudget,
|
||||
reset = { session, keepPlaying ->
|
||||
(session.player as ExoPlayer).apply {
|
||||
repeatMode = if (keepPlaying) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF
|
||||
|
||||
Reference in New Issue
Block a user