Skip to content

Commit 9cd3c36

Browse files
authored
fix(replay): Fix NoSuchElementException in BufferCaptureStrategy (#4717)
* fix(replay): Consider concurrent access to ReplayCache * Changelog * Remove failed frame from snapshot list too
1 parent 72bc1b4 commit 9cd3c36

File tree

6 files changed

+203
-19
lines changed

6 files changed

+203
-19
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919

2020
- Remove internal API status from get/setDistinctId ([#4708](https://github.com/getsentry/sentry-java/pull/4708))
2121

22+
### Fixes
23+
24+
- Fix `NoSuchElementException` in `BufferCaptureStrategy` ([#4717](https://github.com/getsentry/sentry-java/pull/4717))
25+
2226
### Dependencies
2327

2428
- Bump Native SDK from v0.10.0 to v0.10.1 ([#4695](https://github.com/getsentry/sentry-java/pull/4695))

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

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,11 @@ public class ReplayCache(private val options: SentryOptions, private val replayI
4242
private val isClosed = AtomicBoolean(false)
4343
private val encoderLock = AutoClosableReentrantLock()
4444
private val lock = AutoClosableReentrantLock()
45+
private val framesLock = AutoClosableReentrantLock()
4546
private var encoder: SimpleVideoEncoder? = null
4647

4748
internal val replayCacheDir: File? by lazy { makeReplayCacheDir(options, replayId) }
4849

49-
// TODO: maybe account for multi-threaded access
5050
internal val frames = mutableListOf<ReplayFrame>()
5151

5252
private val ongoingSegment = LinkedHashMap<String, String>()
@@ -98,9 +98,13 @@ public class ReplayCache(private val options: SentryOptions, private val replayI
9898
*/
9999
public fun addFrame(screenshot: File, frameTimestamp: Long, screen: String? = null) {
100100
val frame = ReplayFrame(screenshot, frameTimestamp, screen)
101-
frames += frame
101+
framesLock.acquire().use { frames += frame }
102102
}
103103

104+
/** Returns the timestamp of the first frame if available in a thread-safe manner. */
105+
internal fun firstFrameTimestamp(): Long? =
106+
framesLock.acquire().use { frames.firstOrNull()?.timestamp }
107+
104108
/**
105109
* Creates a video out of currently stored [frames] given the start time and duration using the
106110
* on-device codecs [android.media.MediaCodec]. The generated video will be stored in [videoFile]
@@ -134,7 +138,10 @@ public class ReplayCache(private val options: SentryOptions, private val replayI
134138
if (videoFile.exists() && videoFile.length() > 0) {
135139
videoFile.delete()
136140
}
137-
if (frames.isEmpty()) {
141+
// Work on a snapshot of frames to avoid races with writers
142+
val framesSnapshot =
143+
framesLock.acquire().use { if (frames.isEmpty()) mutableListOf() else frames.toMutableList() }
144+
if (framesSnapshot.isEmpty()) {
138145
options.logger.log(DEBUG, "No captured frames, skipping generating a video segment")
139146
return null
140147
}
@@ -156,9 +163,9 @@ public class ReplayCache(private val options: SentryOptions, private val replayI
156163

157164
val step = 1000 / frameRate.toLong()
158165
var frameCount = 0
159-
var lastFrame: ReplayFrame? = frames.first()
166+
var lastFrame: ReplayFrame? = framesSnapshot.firstOrNull()
160167
for (timestamp in from until (from + (duration)) step step) {
161-
val iter = frames.iterator()
168+
val iter = framesSnapshot.iterator()
162169
while (iter.hasNext()) {
163170
val frame = iter.next()
164171
if (frame.timestamp in (timestamp..timestamp + step)) {
@@ -180,7 +187,8 @@ public class ReplayCache(private val options: SentryOptions, private val replayI
180187
// if we failed to encode the frame, we delete the screenshot right away as the
181188
// likelihood of it being able to be encoded later is low
182189
deleteFile(lastFrame.screenshot)
183-
frames.remove(lastFrame)
190+
framesLock.acquire().use { frames.remove(lastFrame) }
191+
framesSnapshot.remove(lastFrame)
184192
lastFrame = null
185193
}
186194
}
@@ -240,14 +248,16 @@ public class ReplayCache(private val options: SentryOptions, private val replayI
240248
*/
241249
internal fun rotate(until: Long): String? {
242250
var screen: String? = null
243-
frames.removeAll {
244-
if (it.timestamp < until) {
245-
deleteFile(it.screenshot)
246-
return@removeAll true
247-
} else if (screen == null) {
248-
screen = it.screen
251+
framesLock.acquire().use {
252+
frames.removeAll {
253+
if (it.timestamp < until) {
254+
deleteFile(it.screenshot)
255+
return@removeAll true
256+
} else if (screen == null) {
257+
screen = it.screen
258+
}
259+
return@removeAll false
249260
}
250-
return@removeAll false
251261
}
252262
return screen
253263
}

sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -217,12 +217,10 @@ internal class BufferCaptureStrategy(
217217
val errorReplayDuration = options.sessionReplay.errorReplayDuration
218218
val now = dateProvider.currentTimeMillis
219219
val currentSegmentTimestamp =
220-
if (cache?.frames?.isNotEmpty() == true) {
220+
cache?.firstFrameTimestamp()?.let {
221221
// in buffer mode we have to set the timestamp of the first frame as the actual start
222-
DateUtils.getDateTime(cache!!.frames.first().timestamp)
223-
} else {
224-
DateUtils.getDateTime(now - errorReplayDuration)
225-
}
222+
DateUtils.getDateTime(it)
223+
} ?: DateUtils.getDateTime(now - errorReplayDuration)
226224
val duration = now - currentSegmentTimestamp.time
227225
val replayId = currentReplayId
228226

sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ internal class SessionCaptureStrategy(
9898
}
9999

100100
if (currentConfig == null) {
101-
options.logger.log(DEBUG, "Recorder config is not set, not recording frame")
101+
options.logger.log(DEBUG, "Recorder config is not set, not capturing a segment")
102102
return@submitSafely
103103
}
104104

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

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import io.sentry.rrweb.RRWebInteractionEvent
2222
import io.sentry.rrweb.RRWebInteractionEvent.InteractionType.TouchEnd
2323
import io.sentry.rrweb.RRWebInteractionEvent.InteractionType.TouchStart
2424
import java.io.File
25+
import java.util.concurrent.CountDownLatch
26+
import java.util.concurrent.atomic.AtomicReference
2527
import kotlin.test.BeforeTest
2628
import kotlin.test.Test
2729
import kotlin.test.assertEquals
@@ -493,4 +495,106 @@ class ReplayCacheTest {
493495
assertTrue(replayCache.frames.isEmpty())
494496
assertTrue(replayCache.replayCacheDir!!.listFiles()!!.none { it.extension == "jpg" })
495497
}
498+
499+
@Test
500+
fun `firstFrameTimestamp returns first timestamp when available`() {
501+
val replayCache = fixture.getSut(tmpDir)
502+
503+
assertNull(replayCache.firstFrameTimestamp())
504+
505+
val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888)
506+
replayCache.addFrame(bitmap, 42)
507+
replayCache.addFrame(bitmap, 1001)
508+
509+
assertEquals(42L, replayCache.firstFrameTimestamp())
510+
}
511+
512+
@Test
513+
fun `firstFrameTimestamp is safe under concurrent rotate and add`() {
514+
val replayCache = fixture.getSut(tmpDir)
515+
516+
val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888)
517+
repeat(10) { i -> replayCache.addFrame(bitmap, (i + 1).toLong()) }
518+
519+
val start = CountDownLatch(1)
520+
val done = CountDownLatch(2)
521+
val error = AtomicReference<Throwable?>()
522+
523+
val tReader = Thread {
524+
try {
525+
start.await()
526+
repeat(500) {
527+
replayCache.firstFrameTimestamp()
528+
Thread.yield()
529+
}
530+
} catch (t: Throwable) {
531+
error.set(t)
532+
} finally {
533+
done.countDown()
534+
}
535+
}
536+
537+
val tWriter = Thread {
538+
try {
539+
start.await()
540+
repeat(500) { i ->
541+
if (i % 2 == 0) {
542+
// delete all frames occasionally
543+
replayCache.rotate(Long.MAX_VALUE)
544+
} else {
545+
// add a fresh frame
546+
replayCache.addFrame(bitmap, System.currentTimeMillis())
547+
}
548+
}
549+
} catch (t: Throwable) {
550+
error.set(t)
551+
} finally {
552+
done.countDown()
553+
}
554+
}
555+
556+
tReader.start()
557+
tWriter.start()
558+
start.countDown()
559+
done.await()
560+
561+
// No crash is success
562+
assertNull(error.get())
563+
}
564+
565+
@Test
566+
fun `createVideoOf tolerates concurrent rotate without crashing`() {
567+
ReplayShadowMediaCodec.framesToEncode = 3
568+
val replayCache = fixture.getSut(tmpDir)
569+
570+
val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888)
571+
// prepare a few frames that might be deleted during encoding
572+
replayCache.addFrame(bitmap, 1)
573+
replayCache.addFrame(bitmap, 1001)
574+
replayCache.addFrame(bitmap, 2001)
575+
576+
val start = CountDownLatch(1)
577+
val done = CountDownLatch(1)
578+
val error = AtomicReference<Throwable?>()
579+
580+
val tEncoder = Thread {
581+
try {
582+
start.await()
583+
replayCache.createVideoOf(3000L, 0L, 0, 100, 200, 1, 20_000)
584+
} catch (t: Throwable) {
585+
error.set(t)
586+
} finally {
587+
done.countDown()
588+
}
589+
}
590+
591+
tEncoder.start()
592+
start.countDown()
593+
// rotate while encoding to simulate concurrent mutation
594+
replayCache.rotate(Long.MAX_VALUE)
595+
done.await()
596+
597+
// No crash is success
598+
assertNull(error.get())
599+
}
496600
}

sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,74 @@ class BufferCaptureStrategyTest {
254254
assertEquals(ReplayType.BUFFER, converted.replayType)
255255
}
256256

257+
@Test
258+
fun `createCurrentSegment uses first frame timestamp when available`() {
259+
val now = System.currentTimeMillis()
260+
val strategy = fixture.getSut(dateProvider = { now })
261+
strategy.start()
262+
strategy.onConfigurationChanged(fixture.recorderConfig)
263+
264+
// Stub first frame timestamp and capture the 'from' argument to createVideoOf
265+
whenever(fixture.replayCache.firstFrameTimestamp()).thenReturn(1234L)
266+
267+
var capturedFrom: Long = -1
268+
whenever(
269+
fixture.replayCache.createVideoOf(
270+
anyLong(),
271+
anyLong(),
272+
anyInt(),
273+
anyInt(),
274+
anyInt(),
275+
anyInt(),
276+
anyInt(),
277+
any(),
278+
)
279+
)
280+
.thenAnswer { invocation ->
281+
capturedFrom = invocation.arguments[1] as Long
282+
GeneratedVideo(File("0.mp4"), 5, VIDEO_DURATION)
283+
}
284+
285+
strategy.pause()
286+
287+
assertEquals(1234L, capturedFrom)
288+
assertEquals(1, strategy.currentSegment)
289+
}
290+
291+
@Test
292+
fun `createCurrentSegment falls back to buffer start when no frames`() {
293+
val now = System.currentTimeMillis()
294+
val strategy = fixture.getSut(dateProvider = { now })
295+
strategy.start()
296+
strategy.onConfigurationChanged(fixture.recorderConfig)
297+
298+
// No frames available
299+
whenever(fixture.replayCache.firstFrameTimestamp()).thenReturn(null)
300+
301+
var capturedFrom: Long = -1
302+
whenever(
303+
fixture.replayCache.createVideoOf(
304+
anyLong(),
305+
anyLong(),
306+
anyInt(),
307+
anyInt(),
308+
anyInt(),
309+
anyInt(),
310+
anyInt(),
311+
any(),
312+
)
313+
)
314+
.thenAnswer { invocation ->
315+
capturedFrom = invocation.arguments[1] as Long
316+
GeneratedVideo(File("0.mp4"), 5, VIDEO_DURATION)
317+
}
318+
319+
strategy.pause()
320+
321+
assertEquals(now - fixture.options.sessionReplay.errorReplayDuration, capturedFrom)
322+
assertEquals(1, strategy.currentSegment)
323+
}
324+
257325
@Test
258326
fun `captureReplay does not replayId to scope when not sampled`() {
259327
val strategy = fixture.getSut(onErrorSampleRate = 0.0)

0 commit comments

Comments
 (0)