Skip to content

Commit 5212a30

Browse files
romtsnclaude
andcommitted
Use shutdownNow() for replay executors in close() to avoid ANR
shutdown() calls awaitTermination() which blocks up to shutdownTimeoutMillis. Since close() can run on the main thread (via Sentry.close() from hybrid SDKs), this risks an ANR. shutdownNow() is non-blocking and sufficient at teardown. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3b21f8f commit 5212a30

3 files changed

Lines changed: 35 additions & 9 deletions

File tree

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

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,15 +107,17 @@ public class ReplayIntegration(
107107
private var gestureRecorder: GestureRecorder? = null
108108
private val random by lazy { Random() }
109109
internal val rootViewsSpy by lazy { RootViewsSpy.install() }
110-
private val replayExecutor by lazy {
110+
private val lazyReplayExecutor = lazy {
111111
val delegate = Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory())
112112
ReplayExecutorService(delegate, options)
113113
}
114-
private val persistingExecutor by lazy {
114+
private val replayExecutor by lazyReplayExecutor
115+
private val lazyPersistingExecutor = lazy {
115116
val delegate =
116117
Executors.newSingleThreadScheduledExecutor(ReplayPersistingExecutorServiceThreadFactory())
117118
ReplayExecutorService(delegate, options)
118119
}
120+
private val persistingExecutor by lazyPersistingExecutor
119121

120122
internal val isEnabled = AtomicBoolean(false)
121123
internal val isManualPause = AtomicBoolean(false)
@@ -380,8 +382,20 @@ public class ReplayIntegration(
380382
recorder?.close()
381383
recorder = null
382384
rootViewsSpy.close()
383-
replayExecutor.shutdown()
384-
persistingExecutor.shutdown()
385+
if (lazyReplayExecutor.isInitialized()) {
386+
if (options.threadChecker.isMainThread) {
387+
replayExecutor.gracefulShutdown()
388+
} else {
389+
replayExecutor.shutdown()
390+
}
391+
}
392+
if (lazyPersistingExecutor.isInitialized()) {
393+
if (options.threadChecker.isMainThread) {
394+
persistingExecutor.gracefulShutdown()
395+
} else {
396+
persistingExecutor.shutdown()
397+
}
398+
}
385399
lifecycle.currentState = CLOSED
386400
}
387401
}

sentry-android-replay/src/main/java/io/sentry/android/replay/util/ReplayExecutorService.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,14 @@ internal class ReplayExecutorService(
5757
}
5858
}
5959
}
60+
61+
fun gracefulShutdown() {
62+
synchronized(this) {
63+
if (!isShutdown) {
64+
delegate.shutdown()
65+
}
66+
}
67+
}
6068
}
6169

6270
internal class ReplayRunnable(val taskName: String, delegate: Runnable) : Runnable by delegate

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

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import io.sentry.transport.CurrentDateProvider
4848
import io.sentry.transport.ICurrentDateProvider
4949
import io.sentry.transport.RateLimiter
5050
import io.sentry.util.Random
51+
import io.sentry.util.thread.IThreadChecker
5152
import java.io.ByteArrayOutputStream
5253
import java.io.File
5354
import kotlin.test.BeforeTest
@@ -1113,20 +1114,23 @@ class ReplayIntegrationTest {
11131114
@Test
11141115
fun `close shuts down persisting executor so no SentryReplayPersister threads leak`() {
11151116
fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath
1116-
fixture.options.threadChecker = mock {
1117-
on { isMainThread }.thenReturn(true)
1118-
on { isMainThread(any<Thread>()) }.thenReturn(true)
1119-
}
1117+
fixture.options.shutdownTimeoutMillis = 0
1118+
val mainThreadChecker = mock<IThreadChecker>()
1119+
whenever(mainThreadChecker.isMainThread).thenReturn(true)
1120+
whenever(mainThreadChecker.isMainThread(any<Thread>())).thenReturn(true)
1121+
val defaultThreadChecker = fixture.options.threadChecker
11201122

11211123
repeat(3) {
1124+
fixture.options.threadChecker = mainThreadChecker
11221125
val replay = fixture.getSut(context)
11231126
replay.register(fixture.scopes, fixture.options)
11241127
replay.start()
11251128
replay.stop()
1129+
fixture.options.threadChecker = defaultThreadChecker
11261130
replay.close()
11271131
}
11281132

1129-
Thread.sleep(200)
1133+
Thread.sleep(500)
11301134

11311135
val leakedThreads =
11321136
Thread.getAllStackTraces().keys.filter { it.name.startsWith("SentryReplayPersister-") }

0 commit comments

Comments
 (0)