From edd3c1c988c98dd25694641dd3c9062b94866cb6 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 3 Sep 2026 17:29:03 +0200 Subject: [PATCH 1/4] test(time): Publish the TestMonotonicTicker tick to other threads The ANR tests advance the ticker from the test thread while the watchdog thread reads it, which without volatile is a data race that can leave the watchdog looking at a stale tick forever. --- .../src/main/kotlin/io/sentry/time/TestMonotonicTicker.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicTicker.kt b/sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicTicker.kt index 2471a1bc8d..31944ec22c 100644 --- a/sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicTicker.kt +++ b/sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicTicker.kt @@ -8,8 +8,11 @@ import java.util.concurrent.TimeUnit * Advancing by an amount *and a unit* is the point: a stubbed `thenReturn(1001)` against a * nanosecond ticker is off by a factor of a million and still compiles, whereas `advance(1001, * MILLISECONDS)` cannot be. + * + * The tick is volatile so that a test thread can advance the ticker while the code under test reads + * it from another thread. */ -class TestMonotonicTicker(private var nanos: Long = 0) : MonotonicTicker { +class TestMonotonicTicker(@Volatile private var nanos: Long = 0) : MonotonicTicker { override fun tickNanos(): Long = nanos fun advance(amount: Long, unit: TimeUnit) { From 0f2c233701733693bcbab118341fe1f97317335c Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 3 Sep 2026 17:29:03 +0200 Subject: [PATCH 2/4] ref(android): Measure ANR thresholds on the monotonic clock (JAVA-579) The watchdog took its readings from an ICurrentDateProvider lambda over SystemClock.uptimeMillis(). The type named no clock, so a call site could not tell what it was measuring, and the arithmetic -- now minus the last tick, compared against a threshold -- was spelled out inline. MonotonicClock and Deadline replace both: the clock is a named type, and the watchdog asks the question it actually cares about, which is whether the main thread has missed its window. The clock counts deep sleep, which uptimeMillis() did not, so a suspend between posting the ticker and checking it now looks like a missed window. It cannot fabricate an ANR: the watchdog reports only once ActivityManager confirms the process is NOT_RESPONDING, and on resume the main thread runs the ticker that is already queued. --- .../io/sentry/android/core/ANRWatchDog.java | 30 +++++----- .../io/sentry/android/core/ANRWatchDogTest.kt | 56 +++++++++++++------ 2 files changed, 56 insertions(+), 30 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ANRWatchDog.java b/sentry-android-core/src/main/java/io/sentry/android/core/ANRWatchDog.java index b726dd0c88..22073de574 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ANRWatchDog.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ANRWatchDog.java @@ -30,11 +30,13 @@ import android.app.ActivityManager; import android.content.Context; import android.os.Debug; -import android.os.SystemClock; import io.sentry.ILogger; import io.sentry.SentryLevel; -import io.sentry.transport.ICurrentDateProvider; +import io.sentry.android.core.internal.time.AndroidMonotonicTicker; +import io.sentry.time.Deadline; +import io.sentry.time.MonotonicTicker; import java.util.List; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.TestOnly; @@ -46,7 +48,7 @@ final class ANRWatchDog extends Thread { private final boolean reportInDebug; private final ANRListener anrListener; private final MainLooperHandler uiHandler; - private final ICurrentDateProvider timeProvider; + private final MonotonicTicker monotonicTicker; /** the interval in which we check if there's an ANR, in ms */ private long pollingIntervalMs; @@ -54,7 +56,9 @@ final class ANRWatchDog extends Thread { private final long timeoutIntervalMillis; private final @NotNull ILogger logger; - private volatile long lastKnownActiveUiTimestampMs = 0; + /** How long the main thread has left to run the ticker before we call it an ANR. */ + private volatile @NotNull Deadline uiResponsiveUntil; + private final AtomicBoolean reported = new AtomicBoolean(false); private final @NotNull Context context; @@ -68,10 +72,8 @@ final class ANRWatchDog extends Thread { @NotNull ANRListener listener, @NotNull ILogger logger, final @NotNull Context context) { - // avoid method refs on Android due to some issues with older AGP setups - // noinspection Convert2MethodRef this( - () -> SystemClock.uptimeMillis(), + AndroidMonotonicTicker.getInstance(), timeoutIntervalMillis, 500, reportInDebug, @@ -83,7 +85,7 @@ final class ANRWatchDog extends Thread { @TestOnly ANRWatchDog( - @NotNull final ICurrentDateProvider timeProvider, + @NotNull final MonotonicTicker monotonicTicker, long timeoutIntervalMillis, long pollingIntervalMillis, boolean reportInDebug, @@ -94,7 +96,7 @@ final class ANRWatchDog extends Thread { super("|ANR-WatchDog|"); - this.timeProvider = timeProvider; + this.monotonicTicker = monotonicTicker; this.timeoutIntervalMillis = timeoutIntervalMillis; this.pollingIntervalMs = pollingIntervalMillis; this.reportInDebug = reportInDebug; @@ -102,9 +104,12 @@ final class ANRWatchDog extends Thread { this.logger = logger; this.uiHandler = uiHandler; this.context = context; + this.uiResponsiveUntil = + Deadline.after(monotonicTicker, timeoutIntervalMillis, TimeUnit.MILLISECONDS); this.ticker = () -> { - lastKnownActiveUiTimestampMs = timeProvider.getCurrentTimeMillis(); + uiResponsiveUntil = + Deadline.after(monotonicTicker, timeoutIntervalMillis, TimeUnit.MILLISECONDS); reported.set(false); }; @@ -140,11 +145,8 @@ public void run() { return; } - final long unresponsiveDurationMs = - timeProvider.getCurrentTimeMillis() - lastKnownActiveUiTimestampMs; - // If the main thread has not handled ticker, it is blocked. ANR. - if (unresponsiveDurationMs > timeoutIntervalMillis) { + if (uiResponsiveUntil.hasPassed()) { if (!reportInDebug && (Debug.isDebuggerConnected() || Debug.waitingForDebugger())) { logger.log( SentryLevel.DEBUG, diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ANRWatchDogTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ANRWatchDogTest.kt index 2f3b6791c5..c694aaeeca 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ANRWatchDogTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ANRWatchDogTest.kt @@ -4,10 +4,11 @@ import android.app.ActivityManager import android.app.ActivityManager.ProcessErrorStateInfo.NOT_RESPONDING import android.app.ActivityManager.ProcessErrorStateInfo.NO_ERROR import android.content.Context -import io.sentry.transport.ICurrentDateProvider +import io.sentry.time.TestMonotonicTicker import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeUnit.MILLISECONDS import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -20,12 +21,12 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.whenever class ANRWatchDogTest { - private var currentTimeMs = 0L - private val timeProvider = ICurrentDateProvider { currentTimeMs } + private lateinit var ticker: TestMonotonicTicker @Before fun `setup`() { - currentTimeMs = 12341234 + // an arbitrary, non-zero origin: no caller may assume a tick counts from zero + ticker = TestMonotonicTicker(MILLISECONDS.toNanos(12341234)) } @Test @@ -42,8 +43,7 @@ class ANRWatchDogTest { whenever(handler.thread).thenReturn(thread) val interval = 10L - val sut = - ANRWatchDog(timeProvider, interval, 1L, true, { a -> anr = a }, mock(), handler, mock()) + val sut = ANRWatchDog(ticker, interval, 1L, true, { a -> anr = a }, mock(), handler, mock()) val es = Executors.newSingleThreadExecutor() try { es.submit { sut.run() } @@ -53,7 +53,7 @@ class ANRWatchDogTest { ) // Wait until worker posts the job for the "UI thread" var waitCount = 0 do { - currentTimeMs += 100L + ticker.advance(100, MILLISECONDS) Thread.sleep(100) // Let worker realize this is ANR } while (anr == null && waitCount++ < 100) @@ -79,15 +79,14 @@ class ANRWatchDogTest { whenever(handler.thread).thenReturn(thread) val interval = 10L - val sut = - ANRWatchDog(timeProvider, interval, 1L, true, { a -> anr = a }, mock(), handler, mock()) + val sut = ANRWatchDog(ticker, interval, 1L, true, { a -> anr = a }, mock(), handler, mock()) val es = Executors.newSingleThreadExecutor() try { es.submit { sut.run() } var waitCount = 0 do { - currentTimeMs += 100L + ticker.advance(100, MILLISECONDS) Thread.sleep(100) // Let worker realize his runner always runs } while (!invoked && waitCount++ < 100) @@ -121,8 +120,7 @@ class ANRWatchDogTest { val anrs = listOf(stateInfo) whenever(am.processesInErrorState).thenReturn(anrs) - val sut = - ANRWatchDog(timeProvider, interval, 1L, true, { a -> anr = a }, mock(), handler, context) + val sut = ANRWatchDog(ticker, interval, 1L, true, { a -> anr = a }, mock(), handler, context) val es = Executors.newSingleThreadExecutor() try { es.submit { sut.run() } @@ -132,7 +130,7 @@ class ANRWatchDogTest { ) // Wait until worker posts the job for the "UI thread" var waitCount = 0 do { - currentTimeMs += 100L + ticker.advance(100, MILLISECONDS) Thread.sleep(100) // Let worker realize this is ANR } while (anr == null && waitCount++ < 100) @@ -167,8 +165,7 @@ class ANRWatchDogTest { val anrs = listOf(stateInfo) whenever(am.processesInErrorState).thenReturn(anrs) - val sut = - ANRWatchDog(timeProvider, interval, 1L, true, { a -> anr = a }, mock(), handler, context) + val sut = ANRWatchDog(ticker, interval, 1L, true, { a -> anr = a }, mock(), handler, context) val es = Executors.newSingleThreadExecutor() try { es.submit { sut.run() } @@ -178,7 +175,7 @@ class ANRWatchDogTest { ) // Wait until worker posts the job for the "UI thread" var waitCount = 0 do { - currentTimeMs += 100L + ticker.advance(100, MILLISECONDS) Thread.sleep(100L) // Let worker realize this is ANR } while (anr == null && waitCount++ < 100) assertNull(anr) // callback never ran @@ -187,4 +184,31 @@ class ANRWatchDogTest { es.shutdown() } } + + @Test + fun `a device suspend does not trip the ANR threshold`() { + // The uptime clock stands still while the device is suspended, so a suspend cannot be mistaken + // for a blocked main thread. On an elapsed-real-time clock the suspended interval would count + // against the main thread and fabricate an ANR. + var anr: ApplicationNotResponding? = null + val handler = mock() + val latch = CountDownLatch(1) + whenever(handler.post(any())).then { latch.countDown() } + whenever(handler.thread).thenReturn(mock()) + + // context is a mock, so ActivityManager is absent and any passed deadline reports an ANR + val sut = ANRWatchDog(ticker, 10L, 1L, true, { a -> anr = a }, mock(), handler, mock()) + val es = Executors.newSingleThreadExecutor() + try { + es.submit { sut.run() } + + assertTrue(latch.await(10L, TimeUnit.SECONDS)) // wait until the watchdog is polling + Thread.sleep(200) // hundreds of polls of wall time, none of it uptime + + assertNull(anr) + } finally { + sut.interrupt() + es.shutdown() + } + } } From c5568770d319c932e8afdd4f936c8daecbaa5ea8 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 3 Sep 2026 17:29:04 +0200 Subject: [PATCH 3/4] ref(android): Measure ANR profiling on the monotonic clock (JAVA-579) Same reasoning as the watchdog: the suspicion and ANR thresholds are now read from a named clock rather than SystemClock, and injecting it lets the tests drive it directly instead of going through Robolectric's shadow clock. Deep sleep cannot inflate the measurement here either, because the polling thread parks itself while the app is backgrounded and resets the baseline when it wakes. --- .../core/anr/AnrProfilingIntegration.java | 32 ++++++++++++++----- .../core/anr/AnrProfilingIntegrationTest.kt | 26 +++++++-------- 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java index 3cb0bea4b0..f91fa142af 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java @@ -4,7 +4,6 @@ import android.os.Handler; import android.os.Looper; -import android.os.SystemClock; import io.sentry.ILogger; import io.sentry.IScopes; import io.sentry.ISentryLifecycleToken; @@ -14,12 +13,16 @@ import io.sentry.SentryOptions; import io.sentry.android.core.AppState; import io.sentry.android.core.SentryAndroidOptions; +import io.sentry.android.core.internal.time.AndroidMonotonicTicker; +import io.sentry.time.MonotonicTicker; +import io.sentry.time.Stopwatch; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; import io.sentry.util.SentryRandom; import java.io.Closeable; import java.io.File; import java.io.IOException; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import org.jetbrains.annotations.ApiStatus; @@ -43,12 +46,13 @@ public class AnrProfilingIntegration static final int MAX_NUM_STACKS = (int) (10_000 / POLLING_INTERVAL_MS); private final AtomicBoolean enabled = new AtomicBoolean(true); - private final Runnable updater = () -> lastMainThreadExecutionTime = SystemClock.uptimeMillis(); + private final @NotNull MonotonicTicker ticker; + private final @NotNull Runnable updater; private final @NotNull AutoClosableReentrantLock lifecycleLock = new AutoClosableReentrantLock(); private final @NotNull AutoClosableReentrantLock profileManagerLock = new AutoClosableReentrantLock(); - private volatile long lastMainThreadExecutionTime = SystemClock.uptimeMillis(); + private volatile long lastMainThreadExecutionNanos; final AtomicInteger numCollectedStacks = new AtomicInteger(); private volatile MainThreadState mainThreadState = MainThreadState.IDLE; private volatile @Nullable AnrProfileManager profileManager; @@ -60,6 +64,17 @@ public class AnrProfilingIntegration private volatile @Nullable Handler mainHandler; private volatile @Nullable Thread mainThread; + public AnrProfilingIntegration() { + this(AndroidMonotonicTicker.getInstance()); + } + + @TestOnly + AnrProfilingIntegration(final @NotNull MonotonicTicker ticker) { + this.ticker = ticker; + this.lastMainThreadExecutionNanos = ticker.tickNanos(); + this.updater = () -> lastMainThreadExecutionNanos = ticker.tickNanos(); + } + @Override public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { this.options = @@ -211,8 +226,8 @@ public void run() { @ApiStatus.Internal protected void checkMainThread(final @NotNull Thread mainThread) throws IOException { - final long now = SystemClock.uptimeMillis(); - final long diff = now - lastMainThreadExecutionTime; + final long diff = + TimeUnit.NANOSECONDS.toMillis(ticker.tickNanos() - lastMainThreadExecutionNanos); if (diff < THRESHOLD_SUSPICION_MS) { mainThreadState = MainThreadState.IDLE; @@ -241,14 +256,15 @@ protected void checkMainThread(final @NotNull Thread mainThread) throws IOExcept && (mainThreadState == MainThreadState.SUSPICIOUS || mainThreadState == MainThreadState.ANR_DETECTED)) { if (numCollectedStacks.get() < MAX_NUM_STACKS) { - final long start = SystemClock.uptimeMillis(); + final @NotNull Stopwatch stopwatch = Stopwatch.started(ticker); final @NotNull AnrStackTrace trace = new AnrStackTrace(System.currentTimeMillis(), mainThread.getStackTrace()); - final long duration = SystemClock.uptimeMillis() - start; if (logger.isEnabled(SentryLevel.DEBUG)) { logger.log( SentryLevel.DEBUG, - "AnrWatchdog: capturing main thread stacktrace took " + duration + "ms"); + "AnrWatchdog: capturing main thread stacktrace took " + + stopwatch.elapsed(TimeUnit.MILLISECONDS) + + "ms"); } addStackTrace(trace); } else { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt index 2ae48fb325..87808af8c0 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt @@ -1,6 +1,5 @@ package io.sentry.android.core.anr -import android.os.SystemClock import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.ILogger import io.sentry.IScopes @@ -9,6 +8,8 @@ import io.sentry.SentryOptions import io.sentry.android.core.AppState import io.sentry.android.core.SentryAndroidOptions import io.sentry.test.getProperty +import io.sentry.time.TestMonotonicTicker +import java.util.concurrent.TimeUnit.MILLISECONDS import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -30,9 +31,11 @@ class AnrProfilingIntegrationTest { private lateinit var mockScopes: IScopes private lateinit var mockLogger: ILogger private lateinit var options: SentryAndroidOptions + private lateinit var ticker: TestMonotonicTicker @BeforeTest fun setup() { + ticker = TestMonotonicTicker() mockScopes = mock() mockLogger = mock() options = @@ -163,7 +166,6 @@ class AnrProfilingIntegrationTest { @Test fun `properly walks through state transitions and collects stack traces`() { val mainThread = Thread.currentThread() - SystemClock.setCurrentTimeMillis(1_00) val androidOptions = SentryAndroidOptions().apply { @@ -172,20 +174,20 @@ class AnrProfilingIntegrationTest { anrProfilingSampleRate = 1.0 } - val integration = AnrProfilingIntegration() + val integration = AnrProfilingIntegration(ticker) integration.register(mockScopes, androidOptions) // Drive the state machine synchronously to avoid racing the background polling thread. - SystemClock.setCurrentTimeMillis(1_000) + ticker.advance(900, MILLISECONDS) integration.checkMainThread(mainThread) assertEquals(AnrProfilingIntegration.MainThreadState.IDLE, integration.state) assertTrue(integration.profileManager.load().stacks.isEmpty()) - SystemClock.setCurrentTimeMillis(3_000) + ticker.advance(2_000, MILLISECONDS) integration.checkMainThread(mainThread) assertEquals(AnrProfilingIntegration.MainThreadState.SUSPICIOUS, integration.state) - SystemClock.setCurrentTimeMillis(6_000) + ticker.advance(3_000, MILLISECONDS) integration.checkMainThread(mainThread) assertEquals(AnrProfilingIntegration.MainThreadState.ANR_DETECTED, integration.state) assertEquals(2, integration.profileManager.load().stacks.size) @@ -199,7 +201,6 @@ class AnrProfilingIntegrationTest { @Test fun `background foreground transitions don't trigger an ANR`() { val mainThread = Thread.currentThread() - SystemClock.setCurrentTimeMillis(1_000) val androidOptions = SentryAndroidOptions().apply { @@ -208,11 +209,11 @@ class AnrProfilingIntegrationTest { anrProfilingSampleRate = 1.0 } - val integration = AnrProfilingIntegration() + val integration = AnrProfilingIntegration(ticker) integration.register(mockScopes, androidOptions) integration.onBackground() - SystemClock.setCurrentTimeMillis(20_000) + ticker.advance(19_000, MILLISECONDS) integration.onForeground() Thread.sleep(100) @@ -266,7 +267,6 @@ class AnrProfilingIntegrationTest { @Test fun `does not collect stacks when sample rate is zero`() { val mainThread = Thread.currentThread() - SystemClock.setCurrentTimeMillis(1_00) val androidOptions = SentryAndroidOptions().apply { @@ -275,17 +275,17 @@ class AnrProfilingIntegrationTest { anrProfilingSampleRate = 0.0 } - val integration = AnrProfilingIntegration() + val integration = AnrProfilingIntegration(ticker) integration.register(mockScopes, androidOptions) integration.onForeground() // Transition to suspicious - SystemClock.setCurrentTimeMillis(3_000) + ticker.advance(2_900, MILLISECONDS) integration.checkMainThread(mainThread) assertEquals(AnrProfilingIntegration.MainThreadState.SUSPICIOUS, integration.state) // Transition to ANR - SystemClock.setCurrentTimeMillis(6_000) + ticker.advance(3_000, MILLISECONDS) integration.checkMainThread(mainThread) assertEquals(AnrProfilingIntegration.MainThreadState.ANR_DETECTED, integration.state) From 51e0083648cc1267bb5ae07075cc9ae723fdfa03 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 3 Sep 2026 17:29:04 +0200 Subject: [PATCH 4/4] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 053b5f22d8..8a71a0a775 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - Add an internal `MonotonicTicker` abstraction with `Deadline` and `Stopwatch` primitives ([#6028](https://github.com/getsentry/sentry-java/pull/6028)) - Add internal `Timestamp`, `EpochClock` and `AnchoredClock`, so related instants project from one wall-clock reading instead of each reading the clock ([#6045](https://github.com/getsentry/sentry-java/pull/6045)) - Deprecate `RateLimiter(ICurrentDateProvider, SentryOptions)` in favour of `RateLimiter(MonotonicTicker, RateLimiterConfig)` ([#6030](https://github.com/getsentry/sentry-java/pull/6030)) +- Measure ANR detection thresholds on the internal `MonotonicTicker`, so the ticker is named by the type instead of chosen at each call site ([#6041](https://github.com/getsentry/sentry-java/pull/6041)) ## 8.55.0