Skip to content

Commit bd2c815

Browse files
romtsnclaude
andcommitted
test(replay): Add deadlock test for close() with blocked executor task
Verifies close() completes when an executor task is waiting on lifecycleLock, ensuring shutdown happens outside the lock. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8ab3ef2 commit bd2c815

2 files changed

Lines changed: 42 additions & 1 deletion

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ public class ReplayIntegration(
131131
private var replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null
132132
private var mainLooperHandler: MainLooperHandler = MainLooperHandler()
133133
private var gestureRecorderProvider: (() -> GestureRecorder)? = null
134-
private val lifecycleLock = AutoClosableReentrantLock()
134+
internal val lifecycleLock = AutoClosableReentrantLock()
135135
private val lifecycle = ReplayLifecycle()
136136

137137
override fun register(scopes: IScopes, options: SentryOptions) {

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,14 @@ import io.sentry.transport.CurrentDateProvider
2525
import io.sentry.transport.ICurrentDateProvider
2626
import io.sentry.transport.RateLimiter
2727
import java.time.Duration
28+
import java.util.concurrent.CountDownLatch
2829
import java.util.concurrent.Executors
2930
import java.util.concurrent.TimeUnit
3031
import java.util.concurrent.atomic.AtomicBoolean
3132
import kotlin.test.BeforeTest
3233
import kotlin.test.assertEquals
3334
import kotlin.test.assertNotEquals
35+
import kotlin.test.assertTrue
3436
import org.awaitility.core.ConditionTimeoutException
3537
import org.awaitility.kotlin.await
3638
import org.junit.Rule
@@ -254,6 +256,45 @@ class ReplaySmokeTest {
254256
assertNotEquals(falseReplay.rootViewsSpy, replay.rootViewsSpy)
255257
assertEquals(0, falseReplay.rootViewsSpy.listeners.size)
256258
}
259+
260+
@Test
261+
fun `close does not deadlock when executor task is waiting on lifecycleLock`() {
262+
fixture.options.sessionReplay.sessionSampleRate = 1.0
263+
fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath
264+
265+
val replay = fixture.getSut(context)
266+
replay.register(fixture.scopes, fixture.options)
267+
replay.start()
268+
269+
val taskBlocked = CountDownLatch(1)
270+
val lockReleased = CountDownLatch(1)
271+
272+
// hold lifecycleLock on this thread
273+
val token = replay.lifecycleLock.acquire()
274+
275+
// submit a task on the executor that tries to acquire the same lock — it will block
276+
replay.replayExecutor.submit {
277+
taskBlocked.countDown()
278+
replay.lifecycleLock.acquire().use {}
279+
}
280+
281+
// wait for the executor task to actually be running and blocked
282+
assertTrue(taskBlocked.await(2, TimeUnit.SECONDS))
283+
284+
// release the lock, then close — if shutdown were inside the lock this would deadlock
285+
token.close()
286+
287+
// close() must complete within a reasonable time
288+
val closedInTime = AtomicBoolean(false)
289+
val closeThread = Thread {
290+
replay.close()
291+
closedInTime.set(true)
292+
}
293+
closeThread.start()
294+
closeThread.join(5000)
295+
296+
assertTrue(closedInTime.get(), "close() deadlocked")
297+
}
257298
}
258299

259300
private class ExampleActivity : Activity() {

0 commit comments

Comments
 (0)