Skip to content

Commit dbe756c

Browse files
authored
Merge branch 'main' into feat/feedback-screenshot-attachment
2 parents e6eff53 + 9a425e5 commit dbe756c

25 files changed

Lines changed: 830 additions & 594 deletions

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
## Unreleased
44

5+
### Fixes
6+
7+
- Prevent duplicated breadcrumbs on tombstone-merged native crash events ([#5888](https://github.com/getsentry/sentry-java/pull/5888))
8+
- Prevent a class of Session Replay deadlocks by confining lifecycle state changes to Android's main thread ([#5965](https://github.com/getsentry/sentry-java/pull/5965))
9+
510
### Features
611

712
- Add screenshot attachment button to the Android user feedback widget ([#5828](https://github.com/getsentry/sentry-java/pull/5828))
@@ -10,6 +15,7 @@
1015

1116
### Performance
1217

18+
- Defer starting Session Replay off the SDK initialization critical path ([#5965](https://github.com/getsentry/sentry-java/pull/5965))
1319
- Use manifest metadata resolved at build time to reduce Android SDK initialization overhead ([#5976](https://github.com/getsentry/sentry-java/pull/5976))
1420

1521
### Dependencies

sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -349,16 +349,19 @@ private void setExtras(final @NotNull SentryBaseEvent event) {
349349

350350
@SuppressWarnings("unchecked")
351351
private void setBreadcrumbs(final @NotNull SentryBaseEvent event) {
352+
final List<Breadcrumb> eventBreadcrumbs = event.getBreadcrumbs();
353+
if (eventBreadcrumbs != null && !eventBreadcrumbs.isEmpty()) {
354+
// the event already carries its own breadcrumbs (e.g. a tombstone-merged native
355+
// crash event), so appending the persisted ones here would duplicate entries. Skip the
356+
// disk read altogether since the result would be discarded anyway.
357+
return;
358+
}
352359
final List<Breadcrumb> breadcrumbs =
353360
(List<Breadcrumb>) readFromDisk(options, BREADCRUMBS_FILENAME, List.class);
354361
if (breadcrumbs == null) {
355362
return;
356363
}
357-
if (event.getBreadcrumbs() == null) {
358-
event.setBreadcrumbs(breadcrumbs);
359-
} else {
360-
event.getBreadcrumbs().addAll(breadcrumbs);
361-
}
364+
event.setBreadcrumbs(breadcrumbs);
362365
}
363366

364367
@SuppressWarnings("unchecked")

sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,21 @@ class ApplicationExitInfoEventProcessorTest {
334334
assertEquals("Google Chrome", processed.contexts.browser!!.name)
335335
}
336336

337+
@Test
338+
fun `when backfillable event already has breadcrumbs, does not duplicate them with persisted ones`() {
339+
// simulates a tombstone-merged native crash event, which already carries its own
340+
// breadcrumb history captured at crash time, overlapping with what was persisted to disk
341+
val hint = HintUtils.createWithTypeCheckHint(BackfillableHint())
342+
343+
val processed =
344+
processEvent(hint, populateScopeCache = true) {
345+
breadcrumbs = listOf(Breadcrumb.debug("own-crash-time-breadcrumb"))
346+
}
347+
348+
assertEquals(1, processed.breadcrumbs!!.size)
349+
assertEquals("own-crash-time-breadcrumb", processed.breadcrumbs!![0].message)
350+
}
351+
337352
@Test
338353
fun `when backfillable event is enrichable, does not backfill user ip`() {
339354
val hint = HintUtils.createWithTypeCheckHint(BackfillableHint())
@@ -670,10 +685,11 @@ class ApplicationExitInfoEventProcessorTest {
670685

671686
assertEquals("MainActivity", processed.transaction)
672687
assertEquals(DEBUG, processed.level)
673-
assertEquals(3, processed.breadcrumbs!!.size)
688+
// breadcrumbs already set on the event are preserved as-is, not merged with the persisted
689+
// ones, since the event already carries its own authoritative breadcrumb history
690+
assertEquals(1, processed.breadcrumbs!!.size)
674691
assertEquals("debug", processed.breadcrumbs!![0].type)
675-
assertEquals("debug", processed.breadcrumbs!![1].type)
676-
assertEquals("navigation", processed.breadcrumbs!![2].type)
692+
assertEquals("test", processed.breadcrumbs!![0].message)
677693

678694
assertEquals("debug", processed.environment)
679695
assertEquals("io.sentry.samples@1.1.0+220", processed.release)

sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,7 @@ class SentryAndroidTest {
352352
@Config(sdk = [26])
353353
fun `init starts session replay if app is in foreground`() {
354354
initSentryWithForegroundImportance(true) { _ ->
355+
Shadows.shadowOf(Looper.getMainLooper()).idle()
355356
assertTrue(Sentry.getCurrentHub().options.replayController.isRecording())
356357
}
357358
}
@@ -360,6 +361,7 @@ class SentryAndroidTest {
360361
@Config(sdk = [26])
361362
fun `init does not start session replay if the app is in background`() {
362363
initSentryWithForegroundImportance(false) { _ ->
364+
Shadows.shadowOf(Looper.getMainLooper()).idle()
363365
assertFalse(Sentry.getCurrentHub().options.replayController.isRecording())
364366
}
365367
}

sentry-android-replay/api/sentry-android-replay.api

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/IConne
5858
public fun <init> (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;)V
5959
public fun <init> (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)V
6060
public synthetic fun <init> (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
61-
public fun captureReplay (Ljava/lang/Boolean;)V
61+
public fun captureReplay (Ljava/lang/Boolean;)Lio/sentry/protocol/SentryId;
6262
public fun close ()V
6363
public fun disableDebugMaskingOverlay ()V
6464
public fun enableDebugMaskingOverlay ()V

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

Lines changed: 27 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import java.io.File
2222
import java.io.StringReader
2323
import java.util.Date
2424
import java.util.LinkedList
25-
import java.util.concurrent.TimeUnit.MILLISECONDS
2625
import java.util.concurrent.atomic.AtomicBoolean
2726

2827
/**
@@ -41,7 +40,6 @@ import java.util.concurrent.atomic.AtomicBoolean
4140
public class ReplayCache(private val options: SentryOptions, private val replayId: SentryId) :
4241
Closeable {
4342
private val isClosed = AtomicBoolean(false)
44-
private val encoderLock = AutoClosableReentrantLock()
4543
private val lock = AutoClosableReentrantLock()
4644
private val framesLock = AutoClosableReentrantLock()
4745
private var encoder: SimpleVideoEncoder? = null
@@ -152,28 +150,26 @@ public class ReplayCache(private val options: SentryOptions, private val replayI
152150
}
153151

154152
encoder =
155-
encoderLock.acquire().use {
156-
SimpleVideoEncoder(
157-
options,
158-
MuxerConfig(
159-
file = videoFile,
160-
recordingHeight = height,
161-
recordingWidth = width,
162-
frameRate = frameRate,
163-
bitRate = bitRate,
164-
),
165-
)
166-
.apply {
167-
// the constructor already opened the MediaMuxer, so release it if start() fails,
168-
// otherwise the encoder is never assigned and its resources leak (CloseGuard warning)
169-
try {
170-
start()
171-
} catch (t: Throwable) {
172-
release()
173-
throw t
174-
}
153+
SimpleVideoEncoder(
154+
options,
155+
MuxerConfig(
156+
file = videoFile,
157+
recordingHeight = height,
158+
recordingWidth = width,
159+
frameRate = frameRate,
160+
bitRate = bitRate,
161+
),
162+
)
163+
.apply {
164+
// the constructor already opened the MediaMuxer, so release it if start() fails,
165+
// otherwise the encoder is never assigned and its resources leak (CloseGuard warning)
166+
try {
167+
start()
168+
} catch (t: Throwable) {
169+
release()
170+
throw t
175171
}
176-
}
172+
}
177173

178174
val step = 1000 / frameRate.toLong()
179175
var frameCount = 0
@@ -209,20 +205,15 @@ public class ReplayCache(private val options: SentryOptions, private val replayI
209205

210206
if (frameCount == 0) {
211207
options.logger.log(DEBUG, "Generated a video with no frames, not capturing a replay segment")
212-
encoderLock.acquire().use {
213-
encoder?.release()
214-
encoder = null
215-
}
208+
encoder?.release()
209+
encoder = null
216210
deleteFile(videoFile)
217211
return null
218212
}
219213

220-
var videoDuration: Long
221-
encoderLock.acquire().use {
222-
encoder?.release()
223-
videoDuration = encoder?.duration ?: 0
224-
encoder = null
225-
}
214+
encoder?.release()
215+
val videoDuration = encoder?.duration ?: 0
216+
encoder = null
226217

227218
rotate(until = (from + duration))
228219

@@ -235,7 +226,7 @@ public class ReplayCache(private val options: SentryOptions, private val replayI
235226
}
236227
return try {
237228
val bitmap = BitmapFactory.decodeFile(frame.screenshot.absolutePath)
238-
encoderLock.acquire().use { encoder?.encode(bitmap) }
229+
encoder?.encode(bitmap)
239230
bitmap.recycle()
240231
true
241232
} catch (e: Throwable) {
@@ -281,27 +272,10 @@ public class ReplayCache(private val options: SentryOptions, private val replayI
281272
}
282273

283274
override fun close() {
284-
// close() is called inline from the lifecycle path (ReplayIntegration.stop/close), which holds
285-
// its own lock, so blocking here can freeze the main thread. If the encoder is wedged in a
286-
// native MediaCodec call we'd never get the lock, so we give up instead: the already-dead codec
287-
// is not released (leaking a native handle), which beats an ANR.
288275
try {
289-
val token = encoderLock.tryAcquire(ENCODER_RELEASE_TIMEOUT_MS, MILLISECONDS)
290-
if (token == null) {
291-
options.logger.log(
292-
WARNING,
293-
"Timed out waiting for the video encoder, skipping its release to not block the caller",
294-
)
295-
} else {
296-
token.use {
297-
encoder?.release()
298-
encoder = null
299-
}
300-
}
301-
} catch (e: InterruptedException) {
302-
Thread.currentThread().interrupt()
276+
encoder?.release()
277+
encoder = null
303278
} finally {
304-
// has to happen on all paths, callers rely on it to stop persisting segment values
305279
isClosed.set(true)
306280
}
307281
}
@@ -333,13 +307,6 @@ public class ReplayCache(private val options: SentryOptions, private val replayI
333307
}
334308

335309
internal companion object {
336-
/**
337-
* How long [close] waits for the video encoder to become available. Below Android's ~5s ANR
338-
* budget, and above the encoder's own bail-out (see MAX_EOS_STALL_ITERATIONS), so an encoder
339-
* that's merely slow is still awaited rather than abandoned.
340-
*/
341-
private const val ENCODER_RELEASE_TIMEOUT_MS = 2000L
342-
343310
internal const val ONGOING_SEGMENT = ".ongoing_segment"
344311

345312
internal const val SEGMENT_KEY_HEIGHT = "config.height"

0 commit comments

Comments
 (0)