fix(replay): Don't let a wedged video encoder freeze the app - #5842
fix(replay): Don't let a wedged video encoder freeze the app#5842romtsn wants to merge 3 commits into
Conversation
| * [MediaCodec.signalEndOfInputStream], which used to spin the drain loop forever while holding the | ||
| * encoder lock, wedging the whole replay pipeline (and with it the app's lifecycle callbacks). | ||
| */ | ||
| private const val MAX_EOS_STALL_ITERATIONS = 10 |
There was a problem hiding this comment.
how did we determine 10 here? Just curious. How fast do these iterations happen?
There was a problem hiding this comment.
honestly just gut feeling (10 x 100ms = 1s wait at most). Unfortunately, I don't have a device at hand to reproduce it, but I will order one to actually check it.
On my two devices that I have (HMD Pulse and Pixel 2XL) each iteration takes exactly around 100ms (the full TIMEOUT_USEC), so we're well under a second here.
📲 Install BuildsAndroid
|
Performance metrics 🚀
|
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 6b019b7 | 319.84 ms | 333.15 ms | 13.31 ms |
| d15471f | 322.58 ms | 396.08 ms | 73.50 ms |
| b750b96 | 421.25 ms | 444.09 ms | 22.84 ms |
| d217708 | 411.22 ms | 430.86 ms | 19.63 ms |
| 604a261 | 380.65 ms | 451.27 ms | 70.62 ms |
| 5b1a06b | 352.27 ms | 413.70 ms | 61.43 ms |
| fcec2f2 | 357.47 ms | 447.32 ms | 89.85 ms |
| 7414e9b | 370.39 ms | 422.18 ms | 51.79 ms |
| fcec2f2 | 311.35 ms | 384.94 ms | 73.59 ms |
| f6cdbf0 | 314.19 ms | 357.59 ms | 43.40 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 6b019b7 | 0 B | 0 B | 0 B |
| d15471f | 1.58 MiB | 2.13 MiB | 559.54 KiB |
| b750b96 | 1.58 MiB | 2.10 MiB | 533.20 KiB |
| d217708 | 1.58 MiB | 2.10 MiB | 532.97 KiB |
| 604a261 | 1.58 MiB | 2.10 MiB | 533.42 KiB |
| 5b1a06b | 0 B | 0 B | 0 B |
| fcec2f2 | 1.58 MiB | 2.12 MiB | 551.50 KiB |
| 7414e9b | 0 B | 0 B | 0 B |
| fcec2f2 | 1.58 MiB | 2.12 MiB | 551.51 KiB |
| f6cdbf0 | 0 B | 0 B | 0 B |
runningcode
left a comment
There was a problem hiding this comment.
looks good, i left a question for a discussion!
| val token = encoderLock.tryAcquire(ENCODER_RELEASE_TIMEOUT_MS, MILLISECONDS) | ||
| if (token == null) { | ||
| options.logger.log( | ||
| WARNING, |
There was a problem hiding this comment.
ok so just to check my understanding, we try to get the lock only to make sure nobody else is holding it before calling release but if we can't acquire the lock, what state are we in? is this safe to skip the release? won't we cause a memory leak?
There was a problem hiding this comment.
yup, it could cause a memory leak, but that's the trade-off - if the encoder is wedged in the native layer, calling release would also hang in the native layer anyway. When the process dies the ref should get reclaimed still, so I guess we pick a lesser evil here.
0xadam-brown
left a comment
There was a problem hiding this comment.
A few quick comments for your consideration (no blockers); otherwise lgtm
| encoderLock.acquire().use { | ||
| encoder?.release() | ||
| encoder = null | ||
| // close() is called inline from the lifecycle path (ReplayIntegration.stop/close), which holds |
There was a problem hiding this comment.
m: Thoughts about lifting this decision to the call site? That'd let us reduce how much we peg ReplayCache's implementation to its current caller.
Eg, we could:
- create a new ReplayCache.tryClose() method and have ReplayIntegration call it instead; or
- add a new ShutdownMode param to close() and have ReplayIntegration choose BEST_EFFORT.
Long-term side note: If we had a general pattern where, say, the Integration owned the execution context / threading decisions, we could leave everything beneath it thread _un_safe on the assumption that our Integrations would serialize execution via thread confinement by default. That'd make the bulk of our integration code much easier to implement and reason about.
I haven't looked in detail, but seems like that sort of pattern should be possible, given the very task-oriented nature of our processing + the fact that the host app needs very little from the SDK in real-time.
Some hardware encoders never emit BUFFER_FLAG_END_OF_STREAM after signalEndOfInputStream(), so SimpleVideoEncoder.drainCodec() spun forever while holding encoderLock. ReplayCache.close() then blocked on that lock, and since it runs inline under ReplayIntegration's lifecycleLock, the main thread froze until the system killed the process. Two bounds, both needed: the drain loop now gives up after 10 consecutive no-progress iterations (~1s), and close() only waits 2s for the encoder lock before skipping the release. The loop bound alone isn't enough -- a native dequeueOutputBuffer call can itself never return, since ALooper::awaitResponse has no deadline of its own. Adds AutoClosableReentrantLock.tryAcquire(timeout, unit) for the latter. Fixes getsentry/sentry-dart#3556 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It's an abnormal condition that drops frames, and it fires at most once per segment, so it's worth surfacing without debug logging enabled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Move isClosed.set(true) into a finally block so it runs even on unchecked exceptions. Add a released flag to ReplayShadowMediaCodec and assert that close() releases the encoder when the lock is available, and skips it when timed out. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6d7152c to
228b6e9
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 228b6e9. Configure here.
| assertWithMessage("encoder should be released even when EOS was never signalled") | ||
| .that(ReplayShadowMediaCodec.released) | ||
| .isTrue() | ||
| } |
There was a problem hiding this comment.
Test named for close never invokes close
Low Severity
This test never calls close(); it only exercises createVideoOf, which releases the encoder internally and then nulls the field. Since the encoder reference is already cleared by the time the test ends, a subsequent close() would be a no-op, so the release-on-lock-acquired branch of close() remains uncovered despite the test name asserting otherwise.
Reviewed by Cursor Bugbot for commit 228b6e9. Configure here.
|
|
||
| assertWithMessage("encoder should not be released when the lock times out") | ||
| .that(ReplayShadowMediaCodec.released) | ||
| .isFalse() |
There was a problem hiding this comment.
Assertion cannot fail: encoder still null when wedged
Low Severity
While the codec is parked inside dequeueOutputBuffer, createVideoOf has not yet assigned the encoder field, so it is still null. Whether close() times out or acquires the lock, encoder?.release() is a no-op and released stays false, making this assertion pass unconditionally and giving false confidence that release is genuinely skipped on timeout.
Reviewed by Cursor Bugbot for commit 228b6e9. Configure here.


📜 Description
Fixes an ANR where Android apps freeze on foreground/background transitions until the system kills the process.
A reporter pulled the full deobfuscated 131-thread dump from Play Console (the Sentry ANR event only carries
main, which is why the lock holder was invisible), giving us the exact chain:Two cooperating defects, so two bounds — both are needed:
SimpleVideoEncoder.drainCodec()had awhile (true)with no exit on theendOfStream = truepath. Some hardware encoders never emitBUFFER_FLAG_END_OF_STREAMaftersignalEndOfInputStream(), so the loop spun forever while holdingencoderLock. It now gives up after 10 consecutive no-progress iterations (~1s at the existing 100ms poll) and drops the remaining frames.ReplayCache.close()used an unboundedencoderLock.acquire(). It runs inline on the caller's thread fromBaseCaptureStrategy.stop(), whichReplayIntegration.stop()invokes while holdinglifecycleLock— that nesting is what promotes a stuck encoder into a frozen main thread. It now waits at most 2s and skips the release otherwise.The loop bound alone isn't sufficient: the native frames show
MediaCodec::dequeueOutputBuffer→AMessage::postAndAwaitResponse→ALooper::awaitResponsewithMediaCodec_looperidle.TIMEOUT_USECis handed to the codec looper, which is supposed to reply once it elapses — a dead looper never replies, andawaitResponsehas no deadline of its own. So a single call can never return, and only bounding the lock wait keeps the lifecycle path free.On the timeout path the (already-dead) codec is not released, leaking a native handle. That's the deliberate trade: leaking a handle in a process whose encoder is wedged beats freezing the app.
isClosed.set(true)still runs on every path, sincepersistSegmentValuesgates on it.The other four
encoderLock.acquire()sites inReplayCacheare unchanged — they all run on the replay worker, the thread that would be stuck, so a deadline there buys nothing.Adds
AutoClosableReentrantLock.tryAcquire(timeout, unit)(@ApiStatus.Internal, additive) for the second bound. Returns the token on success andnullon timeout, so callers must branch explicitly rather than silently no-op via?.use {}.Net effect: a wedged codec degrades to "replay stops working for this process" instead of "the app freezes."
Fixes getsentry/sentry-dart#3556
Fixes #5870
💚 How did you test it?
ReplayShadowMediaCodecgained two hooks, since it ignorestimeoutUsand always eventually produces EOS:neverSignalEosandblockOnDequeue. New tests cover both bounds inReplayCacheTest, plus 4 fortryAcquireinAutoClosableReentrantLockTest.Both
ReplayCacheTestcases were mutation-checked — reverting each fix in turn makes the matching test fail with its assertion message rather than hang. Full:sentry-android-replay:testReleaseUnitTestpasses;apiDumpadds exactly the onetryAcquireline.No device repro is available (Redmi Note 14 Pro 5G / Pro+ 5G, Android 16, MediaTek), so the shadow-based tests are the verification of record.
📝 Checklist
sendDefaultPiiis enabled.🤖 Generated with Claude Code