Skip to content

Commit bbcf99a

Browse files
runningcodeclaudegetsentry-bot
authored
fix(replay): Release MediaMuxer when the encoder fails to start (#5607)
* fix(replay): Release MediaMuxer when the encoder fails to start The MediaMuxer is opened eagerly in SimpleVideoEncoder's constructor, but its release() was only reachable on paths that assume start() succeeded. Two cases leaked it: - createVideoOf constructed the encoder and called start() in one expression, so when start() threw the encoder was never assigned and release() could never run. - SimpleVideoEncoder.release() released the muxer as the last statement of the try block, after draining and stopping the codec. Draining a codec that never started throws, skipping the muxer release. Release the encoder if start() throws, and always release the muxer from a finally block so it is freed even when draining/stopping the codec fails. This surfaced as a CloseGuard "resource was acquired but never released" warning. Complements #5583. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * changelog * fix(replay): Skip MediaMuxer stop when no samples were written MediaMuxer.stop() throws IllegalStateException when the muxer was started but no sample was ever written to its track. SimpleMp4FrameMuxer.release() only guarded against the never-started case, so a started-but-empty muxer made release() throw. Because release() runs from SimpleVideoEncoder's finally block, that throw propagates out to createVideoOf, which treats release() as safe cleanup; the encoder is left dangling and the orphan video file is never deleted. Only call stop() when at least one sample was written; muxer.release() on a started-but-not-stopped muxer is safe. * Format code * fix(replay): Guard each native release so cleanup never propagates The finally block in SimpleVideoEncoder.release() released the codec, surface, and muxer without guards. release() is treated by callers such as createVideoOf as safe cleanup, but MediaMuxer.stop() (reached via frameMuxer.release()) can still throw IllegalStateException on a genuine file-finalization failure even when samples were written. That would skip the remaining releases and propagate out of release(). Guard each release independently so failing to free one resource neither skips the others nor escapes to the caller. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Sentry Github Bot <bot+github-bot@sentry.io>
1 parent 4670d89 commit bbcf99a

6 files changed

Lines changed: 60 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Fixes
66

77
- Pin the published Sentry Android SDK's AAR metadata `minCompileSdk` to our `minSdk` (`21`) instead of AGP 9's new default of the SDK's own `compileSdk` (`37`), so apps that depend on the SDK aren't forced to raise their `compileSdk` ([#5823](https://github.com/getsentry/sentry-java/pull/5823))
8+
- Release `MediaMuxer` when the replay video encoder fails to start to avoid a resource leak ([#5607](https://github.com/getsentry/sentry-java/pull/5607))
89

910
### Performance
1011

sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,16 @@ public class ReplayCache(private val options: SentryOptions, private val replayI
162162
bitRate = bitRate,
163163
),
164164
)
165-
.also { it.start() }
165+
.apply {
166+
// the constructor already opened the MediaMuxer, so release it if start() fails,
167+
// otherwise the encoder is never assigned and its resources leak (CloseGuard warning)
168+
try {
169+
start()
170+
} catch (t: Throwable) {
171+
release()
172+
throw t
173+
}
174+
}
166175
}
167176

168177
val step = 1000 / frameRate.toLong()

sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,10 @@ internal class SimpleMp4FrameMuxer(path: String, fps: Float) : SimpleFrameMuxer
6767
}
6868

6969
override fun release() {
70-
// stop() throws if the muxer was never started (e.g. no frame was ever muxed), so we guard it
71-
// to ensure release() is always reached and the underlying resources are freed
72-
if (started) {
70+
// stop() throws unless the muxer was started AND at least one sample was written, so we guard
71+
// it
72+
// to ensure muxer.release() is always reached and the underlying resources are freed
73+
if (started && videoFrames > 0) {
7374
muxer.stop()
7475
}
7576
muxer.release()

sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -287,12 +287,25 @@ internal class SimpleVideoEncoder(
287287
onClose?.invoke()
288288
drainCodec(true)
289289
mediaCodec.stop()
290-
mediaCodec.release()
291-
surface?.release()
292-
293-
frameMuxer.release()
294-
} catch (e: Throwable) {
290+
} catch (e: RuntimeException) {
295291
options.logger.log(DEBUG, "Failed to properly release video encoder", e)
292+
} finally {
293+
// always release the native resources, even if draining/stopping the codec above threw (e.g.
294+
// when the encoder failed to fully start), otherwise they leak (CloseGuard warning). guard
295+
// each
296+
// call so failing to release one resource neither skips the others nor propagates to callers,
297+
// which treat release() as safe cleanup
298+
releaseQuietly("MediaCodec") { mediaCodec.release() }
299+
releaseQuietly("Surface") { surface?.release() }
300+
releaseQuietly("MediaMuxer") { frameMuxer.release() }
301+
}
302+
}
303+
304+
private inline fun releaseQuietly(name: String, block: () -> Unit) {
305+
try {
306+
block()
307+
} catch (e: RuntimeException) {
308+
options.logger.log(DEBUG, "Failed to release $name", e)
296309
}
297310
}
298311
}

sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import java.util.concurrent.atomic.AtomicReference
2828
import kotlin.test.BeforeTest
2929
import kotlin.test.Test
3030
import kotlin.test.assertEquals
31+
import kotlin.test.assertFailsWith
3132
import kotlin.test.assertFalse
3233
import kotlin.test.assertNull
3334
import kotlin.test.assertTrue
@@ -36,6 +37,7 @@ import org.junit.rules.TemporaryFolder
3637
import org.junit.runner.RunWith
3738
import org.robolectric.annotation.Config
3839
import org.robolectric.shadows.ShadowBitmapFactory
40+
import org.robolectric.shadows.ShadowCloseGuard
3941

4042
@RunWith(AndroidJUnit4::class)
4143
@Config(sdk = [26], shadows = [ReplayShadowMediaCodec::class])
@@ -56,6 +58,7 @@ class ReplayCacheTest {
5658
@BeforeTest
5759
fun `set up`() {
5860
ReplayShadowMediaCodec.framesToEncode = 5
61+
ReplayShadowMediaCodec.throwOnStart = false
5962
ShadowBitmapFactory.setAllowInvalidImageData(true)
6063
}
6164

@@ -93,6 +96,26 @@ class ReplayCacheTest {
9396
assertNull(video)
9497
}
9598

99+
@Test
100+
fun `releases the muxer when the encoder fails to start`() {
101+
ReplayShadowMediaCodec.throwOnStart = true
102+
val replayCache = fixture.getSut(tmpDir)
103+
104+
val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888)
105+
replayCache.addFrame(bitmap, 1)
106+
107+
ShadowCloseGuard.reset()
108+
assertFailsWith<IllegalStateException> {
109+
replayCache.createVideoOf(5000L, 0, 0, 100, 200, 1, 20_000)
110+
}
111+
112+
val muxerLeaks =
113+
ShadowCloseGuard.getErrors().filter { error ->
114+
error.stackTrace.any { it.className.contains("MediaMuxer") }
115+
}
116+
assertTrue(muxerLeaks.isEmpty(), "MediaMuxer was not released: $muxerLeaks")
117+
}
118+
96119
@Test
97120
fun `deletes frames after creating a video`() {
98121
ReplayShadowMediaCodec.framesToEncode = 3

sentry-android-replay/src/test/java/io/sentry/android/replay/util/ReplayShadowMediaCodec.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,16 @@ class ReplayShadowMediaCodec : ShadowMediaCodec() {
1515
companion object {
1616
var frameRate = 1
1717
var framesToEncode = 5
18+
var throwOnStart = false
1819
}
1920

2021
private val encoded = AtomicBoolean(false)
2122

2223
@Implementation
2324
fun start() {
25+
if (throwOnStart) {
26+
throw IllegalStateException("Simulated codec start failure")
27+
}
2428
super.native_start()
2529
}
2630

0 commit comments

Comments
 (0)