From 041a4c1c714418a52f26fc20a99cf9fe01ebb699 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 22 Jul 2026 11:12:19 -0400 Subject: [PATCH] fix(playback): enforce the decoder budget when acquiring players MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../playback/playerPool/ExoPlayerPool.kt | 61 +++++++++++++++++++ .../playback/playerPool/MediaSessionPool.kt | 10 ++- .../playback/service/PlaybackService.kt | 8 ++- 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt index e38aa51c0a..24800b9010 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt @@ -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(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() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt index 92b6fa2300..193fd82b7f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt @@ -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() private val cache = - object : LruCache(10) { // up to 10 videos in the screen at the same time + object : LruCache(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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt index 59fca51fdd..4640126b16 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt @@ -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