@@ -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 }
0 commit comments