From 463bd843d047bd4ed4f2e5f9df9b464779f0052d Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 3 Sep 2026 17:29:03 +0200 Subject: [PATCH 1/6] 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 2471a1bc8dd..31944ec22cb 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 9c11a98452b087a53fff3b2c0ca4f726e27a04e3 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 3 Sep 2026 17:29:03 +0200 Subject: [PATCH 2/6] 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 b726dd0c881..22073de5745 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 2f3b6791c5b..c694aaeeca9 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 4d1f5fe9053bcff292f91887b7fda53696982008 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 3 Sep 2026 17:29:04 +0200 Subject: [PATCH 3/6] 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 3cb0bea4b0f..f91fa142af2 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 2ae48fb3253..87808af8c09 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 eda322245b7d324585befeb9062589b5ccc39661 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 3 Sep 2026 17:29:04 +0200 Subject: [PATCH 4/6] changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62bbaabd38e..ee253b9584f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,10 @@ - Sentry can now configure Log4j2 automatically for Spring Boot 3 when `sentry-log4j2` is on the classpath and Log4j2 Core is the active logging backend ([#6072](https://github.com/getsentry/sentry-java/pull/6072)) - Disabled by default for now; enable it and configure levels the same way as described in the Spring Boot 4 entry above (`sentry.logging.enabled=true`) +### Internal + +- Measure ANR detection and ANR profiling durations on the internal `MonotonicTicker` ([#6041](https://github.com/getsentry/sentry-java/pull/6041)) + ## 8.56.0 ### Behavioral Changes From 8fa7d0966f56a18770c68a399034a30987a81776 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 9 Sep 2026 18:07:25 +0200 Subject: [PATCH 5/6] ref(time): Supply the ANR ticker through MonotonicTickerProvider (JAVA-579) Both ANR detectors resolved AndroidMonotonicTicker.getInstance() themselves, so each call site named the concrete Android ticker and neither could be pointed at another one. SentryAndroidOptions already knows which ticker the platform wants, but it is final, so a test cannot override the getter. Add a one-method MonotonicTickerProvider that SentryOptions implements. ANRWatchDog gains a static factory that reads its timeout, debug behavior, logger and ticker off options, replacing the telescoping convenience constructor. AnrProfilingIntegration installs the ticker in register(), which drops the two constructors it had grown and leaves the public no-arg constructor and sentry-android-core.api unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .../io/sentry/android/core/ANRWatchDog.java | 26 ++++++++--------- .../sentry/android/core/AnrIntegration.java | 7 +---- .../core/anr/AnrProfilingIntegration.java | 28 +++++++++++-------- .../core/anr/AnrProfilingIntegrationTest.kt | 9 ++++-- sentry/api/sentry.api | 6 +++- .../main/java/io/sentry/SentryOptions.java | 4 ++- .../sentry/time/MonotonicTickerProvider.java | 18 ++++++++++++ 7 files changed, 62 insertions(+), 36 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/time/MonotonicTickerProvider.java 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 22073de5745..b86ccdd4f27 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 @@ -32,19 +32,19 @@ import android.os.Debug; import io.sentry.ILogger; import io.sentry.SentryLevel; -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; /** A watchdog timer thread that detects when the UI thread has frozen. */ @SuppressWarnings("UnusedReturnValue") final class ANRWatchDog extends Thread { + private static final long DEFAULT_POLLING_INTERVAL_MS = 500; + private final boolean reportInDebug; private final ANRListener anrListener; private final MainLooperHandler uiHandler; @@ -66,24 +66,22 @@ final class ANRWatchDog extends Thread { @SuppressWarnings("UnnecessaryLambda") private final Runnable ticker; - ANRWatchDog( - long timeoutIntervalMillis, - boolean reportInDebug, - @NotNull ANRListener listener, - @NotNull ILogger logger, + /** Reads the timeout, the debug behavior, the logger and the ticker off {@code options}. */ + static @NotNull ANRWatchDog create( + final @NotNull SentryAndroidOptions options, + final @NotNull ANRListener listener, final @NotNull Context context) { - this( - AndroidMonotonicTicker.getInstance(), - timeoutIntervalMillis, - 500, - reportInDebug, + return new ANRWatchDog( + options.getMonotonicTicker(), + options.getAnrTimeoutIntervalMillis(), + DEFAULT_POLLING_INTERVAL_MS, + options.isAnrReportInDebug(), listener, - logger, + options.getLogger(), new MainLooperHandler(), context); } - @TestOnly ANRWatchDog( @NotNull final MonotonicTicker monotonicTicker, long timeoutIntervalMillis, diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java index f37d433d308..275914e3fe2 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java @@ -95,12 +95,7 @@ private void startAnrWatchdog( options.getAnrTimeoutIntervalMillis()); anrWatchDog = - new ANRWatchDog( - options.getAnrTimeoutIntervalMillis(), - options.isAnrReportInDebug(), - error -> reportANR(scopes, options, error), - options.getLogger(), - context); + ANRWatchDog.create(options, error -> reportANR(scopes, options, error), context); anrWatchDog.start(); options.getLogger().log(SentryLevel.DEBUG, "AnrIntegration installed."); 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 f91fa142af2..46f633aae73 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 @@ -13,8 +13,9 @@ 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.JavaMonotonicTicker; import io.sentry.time.MonotonicTicker; +import io.sentry.time.MonotonicTickerProvider; import io.sentry.time.Stopwatch; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; @@ -46,8 +47,11 @@ public class AnrProfilingIntegration static final int MAX_NUM_STACKS = (int) (10_000 / POLLING_INTERVAL_MS); private final AtomicBoolean enabled = new AtomicBoolean(true); - private final @NotNull MonotonicTicker ticker; - private final @NotNull Runnable updater; + private volatile @NotNull MonotonicTicker ticker = JavaMonotonicTicker.getInstance(); + + @SuppressWarnings("UnnecessaryLambda") + private final @NotNull Runnable updater = () -> lastMainThreadExecutionNanos = ticker.tickNanos(); + private final @NotNull AutoClosableReentrantLock lifecycleLock = new AutoClosableReentrantLock(); private final @NotNull AutoClosableReentrantLock profileManagerLock = new AutoClosableReentrantLock(); @@ -64,15 +68,16 @@ 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; + /** + * Installs the ticker to measure main-thread stalls with. Also resets the last-execution reading, + * so a stall is never measured against a tick from a different ticker. + * + *

Package-private so a test can install a fake ticker after {@link #register}, which {@code + * SentryAndroidOptions} cannot supply. + */ + void installTickerFrom(final @NotNull MonotonicTickerProvider provider) { + this.ticker = provider.getMonotonicTicker(); this.lastMainThreadExecutionNanos = ticker.tickNanos(); - this.updater = () -> lastMainThreadExecutionNanos = ticker.tickNanos(); } @Override @@ -82,6 +87,7 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, "SentryAndroidOptions is required"); this.logger = options.getLogger(); + installTickerFrom(options); if (this.options.isAnrProfilingEnabled()) { if (this.options.getCacheDirPath() == null) { 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 87808af8c09..dba2e2eddd3 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 @@ -174,8 +174,9 @@ class AnrProfilingIntegrationTest { anrProfilingSampleRate = 1.0 } - val integration = AnrProfilingIntegration(ticker) + val integration = AnrProfilingIntegration() integration.register(mockScopes, androidOptions) + integration.installTickerFrom { ticker } // Drive the state machine synchronously to avoid racing the background polling thread. ticker.advance(900, MILLISECONDS) @@ -209,8 +210,9 @@ class AnrProfilingIntegrationTest { anrProfilingSampleRate = 1.0 } - val integration = AnrProfilingIntegration(ticker) + val integration = AnrProfilingIntegration() integration.register(mockScopes, androidOptions) + integration.installTickerFrom { ticker } integration.onBackground() ticker.advance(19_000, MILLISECONDS) @@ -275,8 +277,9 @@ class AnrProfilingIntegrationTest { anrProfilingSampleRate = 0.0 } - val integration = AnrProfilingIntegration(ticker) + val integration = AnrProfilingIntegration() integration.register(mockScopes, androidOptions) + integration.installTickerFrom { ticker } integration.onForeground() // Transition to suspicious diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 24635fc5ddd..4236d471ef5 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -3654,7 +3654,7 @@ public final class io/sentry/SentryOpenTelemetryMode : java/lang/Enum { public static fun values ()[Lio/sentry/SentryOpenTelemetryMode; } -public class io/sentry/SentryOptions : io/sentry/transport/RateLimiterConfig { +public class io/sentry/SentryOptions : io/sentry/time/MonotonicTickerProvider, io/sentry/transport/RateLimiterConfig { public static final field DEFAULT_PROPAGATION_TARGETS Ljava/lang/String; public static final field MAX_EVENT_SIZE_BYTES J protected final field lock Lio/sentry/util/AutoClosableReentrantLock; @@ -7626,6 +7626,10 @@ public abstract interface class io/sentry/time/MonotonicTicker { public abstract fun tickNanos ()J } +public abstract interface class io/sentry/time/MonotonicTickerProvider { + public abstract fun getMonotonicTicker ()Lio/sentry/time/MonotonicTicker; +} + public final class io/sentry/time/Stopwatch { public fun elapsed (Ljava/util/concurrent/TimeUnit;)J public fun elapsedNanos ()J diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 280bc01174e..2b06e8783e3 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -24,6 +24,7 @@ import io.sentry.time.EpochClock; import io.sentry.time.JavaMonotonicTicker; import io.sentry.time.MonotonicTicker; +import io.sentry.time.MonotonicTickerProvider; import io.sentry.time.SystemEpochClock; import io.sentry.transport.ITransport; import io.sentry.transport.ITransportGate; @@ -59,7 +60,7 @@ /** Sentry SDK options */ @Open -public class SentryOptions implements RateLimiterConfig { +public class SentryOptions implements RateLimiterConfig, MonotonicTickerProvider { @ApiStatus.Internal public static final @NotNull String DEFAULT_PROPAGATION_TARGETS = ".*"; @@ -3088,6 +3089,7 @@ public void setDateProvider(final @NotNull SentryDateProvider dateProvider) { * which this module cannot reference. On the JVM there is no suspend state to account for. */ @ApiStatus.Internal + @Override public @NotNull MonotonicTicker getMonotonicTicker() { return JavaMonotonicTicker.getInstance(); } diff --git a/sentry/src/main/java/io/sentry/time/MonotonicTickerProvider.java b/sentry/src/main/java/io/sentry/time/MonotonicTickerProvider.java new file mode 100644 index 00000000000..e7cff5d3830 --- /dev/null +++ b/sentry/src/main/java/io/sentry/time/MonotonicTickerProvider.java @@ -0,0 +1,18 @@ +package io.sentry.time; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * Supplies the {@link MonotonicTicker} to measure elapsed time with. + * + *

{@code SentryOptions} implements this, and the Android module overrides it with a ticker the + * core module cannot reference. Depending on this interface rather than on the options class keeps + * the concrete ticker out of call sites, and lets a test supply its own without subclassing the + * final {@code SentryAndroidOptions}. + */ +@ApiStatus.Internal +public interface MonotonicTickerProvider { + @NotNull + MonotonicTicker getMonotonicTicker(); +} From fd94017f4e18819821d437260c0131afc89081cc Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 10 Sep 2026 10:32:29 +0200 Subject: [PATCH 6/6] ref(android): Require an installed ticker for ANR profiling (JAVA-579) Drop the JavaMonotonicTicker fallback. On Android nanoTime() stops while the device is suspended, so a default that is never meant to be used would measure main-thread stalls against the wrong clock if it ever were. Start the test ticker at a non-zero origin. A fake that starts at zero makes a reading taken against a different origin indistinguishable from a correct one, which is the mistake this whole change exists to prevent. Co-Authored-By: Claude Opus 5 (1M context) --- .../core/anr/AnrProfilingIntegration.java | 25 +++++++++++++------ .../core/anr/AnrProfilingIntegrationTest.kt | 18 ++++++++++++- 2 files changed, 35 insertions(+), 8 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 46f633aae73..b77cd76cd37 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 @@ -13,7 +13,6 @@ import io.sentry.SentryOptions; import io.sentry.android.core.AppState; import io.sentry.android.core.SentryAndroidOptions; -import io.sentry.time.JavaMonotonicTicker; import io.sentry.time.MonotonicTicker; import io.sentry.time.MonotonicTickerProvider; import io.sentry.time.Stopwatch; @@ -47,10 +46,17 @@ public class AnrProfilingIntegration static final int MAX_NUM_STACKS = (int) (10_000 / POLLING_INTERVAL_MS); private final AtomicBoolean enabled = new AtomicBoolean(true); - private volatile @NotNull MonotonicTicker ticker = JavaMonotonicTicker.getInstance(); + + private volatile @Nullable MonotonicTicker ticker; @SuppressWarnings("UnnecessaryLambda") - private final @NotNull Runnable updater = () -> lastMainThreadExecutionNanos = ticker.tickNanos(); + private final @NotNull Runnable updater = + () -> { + final @Nullable MonotonicTicker currentTicker = ticker; + if (currentTicker != null) { + lastMainThreadExecutionNanos = currentTicker.tickNanos(); + } + }; private final @NotNull AutoClosableReentrantLock lifecycleLock = new AutoClosableReentrantLock(); private final @NotNull AutoClosableReentrantLock profileManagerLock = @@ -76,8 +82,9 @@ public class AnrProfilingIntegration * SentryAndroidOptions} cannot supply. */ void installTickerFrom(final @NotNull MonotonicTickerProvider provider) { - this.ticker = provider.getMonotonicTicker(); - this.lastMainThreadExecutionNanos = ticker.tickNanos(); + final @NotNull MonotonicTicker installedTicker = provider.getMonotonicTicker(); + this.ticker = installedTicker; + this.lastMainThreadExecutionNanos = installedTicker.tickNanos(); } @Override @@ -232,8 +239,12 @@ public void run() { @ApiStatus.Internal protected void checkMainThread(final @NotNull Thread mainThread) throws IOException { + final @Nullable MonotonicTicker currentTicker = ticker; + if (currentTicker == null) { + return; + } final long diff = - TimeUnit.NANOSECONDS.toMillis(ticker.tickNanos() - lastMainThreadExecutionNanos); + TimeUnit.NANOSECONDS.toMillis(currentTicker.tickNanos() - lastMainThreadExecutionNanos); if (diff < THRESHOLD_SUSPICION_MS) { mainThreadState = MainThreadState.IDLE; @@ -262,7 +273,7 @@ protected void checkMainThread(final @NotNull Thread mainThread) throws IOExcept && (mainThreadState == MainThreadState.SUSPICIOUS || mainThreadState == MainThreadState.ANR_DETECTED)) { if (numCollectedStacks.get() < MAX_NUM_STACKS) { - final @NotNull Stopwatch stopwatch = Stopwatch.started(ticker); + final @NotNull Stopwatch stopwatch = Stopwatch.started(currentTicker); final @NotNull AnrStackTrace trace = new AnrStackTrace(System.currentTimeMillis(), mainThread.getStackTrace()); if (logger.isEnabled(SentryLevel.DEBUG)) { 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 dba2e2eddd3..2f4ac1728e6 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,7 @@ package io.sentry.android.core.anr import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat import io.sentry.ILogger import io.sentry.IScopes import io.sentry.SentryIntegrationPackageStorage @@ -9,6 +10,7 @@ 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.HOURS import java.util.concurrent.TimeUnit.MILLISECONDS import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -35,7 +37,9 @@ class AnrProfilingIntegrationTest { @BeforeTest fun setup() { - ticker = TestMonotonicTicker() + // A tick origin far from zero, as on any device that has been up a while. Starting at zero + // would let a reading taken against a different origin pass unnoticed. + ticker = TestMonotonicTicker(HOURS.toNanos(30)) mockScopes = mock() mockLogger = mock() options = @@ -223,6 +227,18 @@ class AnrProfilingIntegrationTest { assertEquals(AnrProfilingIntegration.MainThreadState.IDLE, integration.state) } + @Test + fun `does not report a stall when the ticker origin is far from zero`() { + val integration = AnrProfilingIntegration() + integration.register(mockScopes, options) + integration.installTickerFrom { ticker } + + integration.checkMainThread(Thread.currentThread()) + + assertThat(integration.state).isEqualTo(AnrProfilingIntegration.MainThreadState.IDLE) + assertThat(integration.profileManager.load().stacks).isEmpty() + } + @Test fun `does not register when options is not SentryAndroidOptions`() { val plainOptions =