From 9281ad45cfd1f7b784ff83a9dc4f47aa86576c37 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 11 Sep 2026 16:04:55 +0200 Subject: [PATCH 1/3] ref(core): Measure the hostname cache TTL on a monotonic ticker (JAVA-579) HostnameCache stored an absolute expiry built from currentTimeMillis and compared it against a fresh reading, so a device time change resized the 5h TTL: a backward step extended it by the size of the step, a forward step expired the cache early. It now holds a Deadline on a MonotonicTicker. The field also no longer starts at 0, which on a boot-relative ticker reads as freshly set rather than as unset, so nothing counts as cached before the first resolve. The TTL is not a serialized value, so this only changes when the SDK re-resolves the hostname. Co-Authored-By: Claude Opus 5 (1M context) --- .../main/java/io/sentry/HostnameCache.java | 42 +++++++++++++------ .../test/java/io/sentry/HostnameCacheTest.kt | 29 ++++++++++--- 2 files changed, 53 insertions(+), 18 deletions(-) diff --git a/sentry/src/main/java/io/sentry/HostnameCache.java b/sentry/src/main/java/io/sentry/HostnameCache.java index 4e9e2bc4c0d..3f29b1226a4 100644 --- a/sentry/src/main/java/io/sentry/HostnameCache.java +++ b/sentry/src/main/java/io/sentry/HostnameCache.java @@ -1,5 +1,8 @@ package io.sentry; +import io.sentry.time.Deadline; +import io.sentry.time.JavaMonotonicTicker; +import io.sentry.time.MonotonicTicker; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; import java.net.InetAddress; @@ -43,14 +46,16 @@ public final class HostnameCache { private static final @NotNull AutoClosableReentrantLock staticLock = new AutoClosableReentrantLock(); - /** Time for which the cache is kept. */ - private final long cacheDuration; + /** Time for which the cache is kept, in milliseconds. */ + private final long cacheDurationMillis; + + private final @NotNull MonotonicTicker ticker; /** Current value for hostname (might change over time). */ @Nullable private volatile String hostname; - /** Time at which the cache should expire. */ - private volatile long expirationTimestamp; + /** When the cached hostname goes stale. */ + private volatile @NotNull Deadline cacheFreshUntil; /** Whether a cache update thread is currently running or not. */ private final @NotNull AtomicBoolean updateRunning = new AtomicBoolean(false); @@ -75,22 +80,34 @@ private HostnameCache() { this(HOSTNAME_CACHE_DURATION); } - HostnameCache(long cacheDuration) { + HostnameCache(long cacheDurationMillis) { // avoid method refs on Android due to some issues with older AGP setups // noinspection Convert2MethodRef - this(cacheDuration, () -> InetAddress.getLocalHost()); + this(cacheDurationMillis, () -> InetAddress.getLocalHost()); + } + + HostnameCache(long cacheDurationMillis, final @NotNull Callable getLocalhost) { + this(cacheDurationMillis, getLocalhost, JavaMonotonicTicker.getInstance()); } /** * Sets up a cache for the hostname. * - * @param cacheDuration cache duration in milliseconds. + * @param cacheDurationMillis cache duration in milliseconds. * @param getLocalhost a callback to obtain the localhost address - this is mostly here because of * testability + * @param ticker the ticker the cache lifetime is measured on */ - HostnameCache(long cacheDuration, final @NotNull Callable getLocalhost) { - this.cacheDuration = cacheDuration; + HostnameCache( + long cacheDurationMillis, + final @NotNull Callable getLocalhost, + final @NotNull MonotonicTicker ticker) { + this.cacheDurationMillis = cacheDurationMillis; this.getLocalhost = Objects.requireNonNull(getLocalhost, "getLocalhost is required"); + this.ticker = Objects.requireNonNull(ticker, "ticker is required"); + // Nothing resolved yet, so the cache is stale rather than fresh until updateCache says + // otherwise. + this.cacheFreshUntil = Deadline.passed(ticker); // A single thread executor whose worker thread times out while idle, so no thread is kept // alive between the infrequent cache refreshes and nothing has to shut it down. final @NotNull ThreadPoolExecutor executor = @@ -115,8 +132,7 @@ private HostnameCache() { */ @Nullable public String getHostname() { - if (expirationTimestamp < System.currentTimeMillis() - && updateRunning.compareAndSet(false, true)) { + if (cacheFreshUntil.hasPassed() && updateRunning.compareAndSet(false, true)) { updateCache(); } @@ -129,7 +145,7 @@ private void updateCache() { () -> { try { hostname = getLocalhost.call().getCanonicalHostName(); - expirationTimestamp = System.currentTimeMillis() + cacheDuration; + cacheFreshUntil = Deadline.after(ticker, cacheDurationMillis, TimeUnit.MILLISECONDS); } finally { updateRunning.set(false); } @@ -162,7 +178,7 @@ private void updateCache() { } private void handleCacheUpdateFailure() { - expirationTimestamp = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(1); + cacheFreshUntil = Deadline.after(ticker, 1, TimeUnit.SECONDS); } private static final class HostnameCacheThreadFactory implements ThreadFactory { diff --git a/sentry/src/test/java/io/sentry/HostnameCacheTest.kt b/sentry/src/test/java/io/sentry/HostnameCacheTest.kt index 6d0398858a3..cf2bc06f4f9 100644 --- a/sentry/src/test/java/io/sentry/HostnameCacheTest.kt +++ b/sentry/src/test/java/io/sentry/HostnameCacheTest.kt @@ -2,7 +2,7 @@ package io.sentry import com.google.common.truth.Truth.assertThat import io.sentry.test.getProperty -import io.sentry.test.injectForField +import io.sentry.time.TestMonotonicTicker import java.net.InetAddress import java.util.concurrent.ThreadPoolExecutor import java.util.concurrent.TimeUnit @@ -25,13 +25,32 @@ class HostnameCacheTest { assertThat(cache.hostname).isEqualTo("myhost") } + @Test + fun `hostname is re-resolved only once the cache duration has elapsed`() { + val ticker = TestMonotonicTicker() + val address = mock() + whenever(address.canonicalHostName).thenReturn("first", "second") + val cache = HostnameCache(TimeUnit.HOURS.toMillis(5), { address }, ticker) + + assertThat(cache.hostname).isEqualTo("first") + + ticker.advance(4, TimeUnit.HOURS) + assertThat(cache.hostname).isEqualTo("first") + + ticker.advance(1, TimeUnit.HOURS) + assertThat(cache.hostname).isEqualTo("second") + } + @Test fun `a refresh that cannot be queued does not stop later refreshes`() { - val cache = getSut() - // Reject the next submit the way an executor that could not start a thread would, and mark the - // cache stale so that reading the hostname attempts a refresh. + val ticker = TestMonotonicTicker() + val address = mock() + whenever(address.canonicalHostName).thenReturn("myhost") + val cache = HostnameCache(TimeUnit.HOURS.toMillis(1), { address }, ticker) + // Reject the next submit the way an executor that could not start a thread would, and let the + // cache go stale so that reading the hostname attempts a refresh. cache.getProperty("executorService").shutdown() - cache.injectForField("expirationTimestamp", 0L) + ticker.advance(1, TimeUnit.HOURS) assertThat(cache.hostname).isEqualTo("myhost") From 7a5b20d3268115f05b5dfb3cafe3ad17af77267a Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 11 Sep 2026 16:06:53 +0200 Subject: [PATCH 2/3] changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbf192eeb33..e169d95abb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Internal + +- Measure the hostname cache TTL on a monotonic ticker, so that a device time change no longer shortens or extends it ([#6100](https://github.com/getsentry/sentry-java/pull/6100)) + ## 8.60.0 ### Features From 2cd4aaf3ed80db1f1e76cdc48c5a1d6902a3822a Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 15 Sep 2026 16:26:27 +0200 Subject: [PATCH 3/3] ref(core): Collapse the HostnameCache constructor chain (JAVA-579) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four constructors were a telescoping chain of test seams: one to shorten the 5-hour TTL, one to stub the localhost lookup, one to fake the ticker. With a fake ticker a test can advance past the real TTL instantly, so the duration parameter has no remaining purpose and the chain collapses to a single seam. HostnameCache(long) had no callers at all — both apparent call sites were Kotlin trailing-lambda calls that SAM-convert to HostnameCache(long, Callable). MainEventProcessorTest's cache-expiry test now advances the ticker instead of polling with awaitility, making it deterministic rather than just fast. Co-Authored-By: Claude Opus 5 (1M context) --- .../main/java/io/sentry/HostnameCache.java | 28 +++++-------------- .../test/java/io/sentry/HostnameCacheTest.kt | 8 +++--- .../java/io/sentry/MainEventProcessorTest.kt | 18 ++++++------ 3 files changed, 20 insertions(+), 34 deletions(-) diff --git a/sentry/src/main/java/io/sentry/HostnameCache.java b/sentry/src/main/java/io/sentry/HostnameCache.java index 3f29b1226a4..96bf845ef59 100644 --- a/sentry/src/main/java/io/sentry/HostnameCache.java +++ b/sentry/src/main/java/io/sentry/HostnameCache.java @@ -25,9 +25,9 @@ * Time sensitive cache in charge of keeping track of the hostname. The {@code * InetAddress.getLocalHost().getCanonicalHostName()} call can be quite expensive and could be * called for the creation of each {@link SentryEvent}. This system will prevent unnecessary costs - * by keeping track of the hostname for a period defined during the construction. For performance - * purposes, the operation of retrieving the hostname will automatically fail after a period of time - * defined by {@link #GET_HOSTNAME_TIMEOUT} without result. + * by keeping track of the hostname for a period defined by {@link #HOSTNAME_CACHE_DURATION}. For + * performance purposes, the operation of retrieving the hostname will automatically fail after a + * period of time defined by {@link #GET_HOSTNAME_TIMEOUT} without result. * *

HostnameCache is a singleton and its instance should be obtained through {@link * HostnameCache#getInstance()}. @@ -46,9 +46,6 @@ public final class HostnameCache { private static final @NotNull AutoClosableReentrantLock staticLock = new AutoClosableReentrantLock(); - /** Time for which the cache is kept, in milliseconds. */ - private final long cacheDurationMillis; - private final @NotNull MonotonicTicker ticker; /** Current value for hostname (might change over time). */ @@ -77,32 +74,20 @@ public final class HostnameCache { } private HostnameCache() { - this(HOSTNAME_CACHE_DURATION); - } - - HostnameCache(long cacheDurationMillis) { // avoid method refs on Android due to some issues with older AGP setups // noinspection Convert2MethodRef - this(cacheDurationMillis, () -> InetAddress.getLocalHost()); - } - - HostnameCache(long cacheDurationMillis, final @NotNull Callable getLocalhost) { - this(cacheDurationMillis, getLocalhost, JavaMonotonicTicker.getInstance()); + this(() -> InetAddress.getLocalHost(), JavaMonotonicTicker.getInstance()); } /** * Sets up a cache for the hostname. * - * @param cacheDurationMillis cache duration in milliseconds. * @param getLocalhost a callback to obtain the localhost address - this is mostly here because of * testability * @param ticker the ticker the cache lifetime is measured on */ HostnameCache( - long cacheDurationMillis, - final @NotNull Callable getLocalhost, - final @NotNull MonotonicTicker ticker) { - this.cacheDurationMillis = cacheDurationMillis; + final @NotNull Callable getLocalhost, final @NotNull MonotonicTicker ticker) { this.getLocalhost = Objects.requireNonNull(getLocalhost, "getLocalhost is required"); this.ticker = Objects.requireNonNull(ticker, "ticker is required"); // Nothing resolved yet, so the cache is stale rather than fresh until updateCache says @@ -145,7 +130,8 @@ private void updateCache() { () -> { try { hostname = getLocalhost.call().getCanonicalHostName(); - cacheFreshUntil = Deadline.after(ticker, cacheDurationMillis, TimeUnit.MILLISECONDS); + cacheFreshUntil = + Deadline.after(ticker, HOSTNAME_CACHE_DURATION, TimeUnit.MILLISECONDS); } finally { updateRunning.set(false); } diff --git a/sentry/src/test/java/io/sentry/HostnameCacheTest.kt b/sentry/src/test/java/io/sentry/HostnameCacheTest.kt index cf2bc06f4f9..b72b9d4051d 100644 --- a/sentry/src/test/java/io/sentry/HostnameCacheTest.kt +++ b/sentry/src/test/java/io/sentry/HostnameCacheTest.kt @@ -16,7 +16,7 @@ class HostnameCacheTest { private fun getSut(): HostnameCache { val address = mock() whenever(address.canonicalHostName).thenReturn("myhost") - return HostnameCache(TimeUnit.HOURS.toMillis(1)) { address } + return HostnameCache({ address }, TestMonotonicTicker()) } @Test @@ -30,7 +30,7 @@ class HostnameCacheTest { val ticker = TestMonotonicTicker() val address = mock() whenever(address.canonicalHostName).thenReturn("first", "second") - val cache = HostnameCache(TimeUnit.HOURS.toMillis(5), { address }, ticker) + val cache = HostnameCache({ address }, ticker) assertThat(cache.hostname).isEqualTo("first") @@ -46,11 +46,11 @@ class HostnameCacheTest { val ticker = TestMonotonicTicker() val address = mock() whenever(address.canonicalHostName).thenReturn("myhost") - val cache = HostnameCache(TimeUnit.HOURS.toMillis(1), { address }, ticker) + val cache = HostnameCache({ address }, ticker) // Reject the next submit the way an executor that could not start a thread would, and let the // cache go stale so that reading the hostname attempts a refresh. cache.getProperty("executorService").shutdown() - ticker.advance(1, TimeUnit.HOURS) + ticker.advance(5, TimeUnit.HOURS) assertThat(cache.hostname).isEqualTo("myhost") diff --git a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt index ed6efffb07c..20c4682d4b5 100644 --- a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt +++ b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt @@ -7,9 +7,11 @@ import io.sentry.protocol.DebugMeta import io.sentry.protocol.SdkVersion import io.sentry.protocol.SentryTransaction import io.sentry.protocol.User +import io.sentry.time.TestMonotonicTicker import io.sentry.util.HintUtils import java.lang.RuntimeException import java.net.InetAddress +import java.util.concurrent.TimeUnit import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals @@ -18,7 +20,6 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertSame import kotlin.test.assertTrue -import org.awaitility.kotlin.await import org.mockito.Mockito import org.mockito.kotlin.mock import org.mockito.kotlin.reset @@ -37,6 +38,7 @@ class MainEventProcessorTest { } val scopes = mock() val getLocalhost = mock() + val hostnameCacheTicker = TestMonotonicTicker() lateinit var sentryTracer: SentryTracer private val hostnameCacheMock = Mockito.mockStatic(HostnameCache::class.java) @@ -49,7 +51,6 @@ class MainEventProcessorTest { serverName: String? = "server", host: String? = null, resolveHostDelay: Long? = null, - hostnameCacheDuration: Long = 10, proguardUuid: String? = null, bundleIds: List? = null, modules: Map? = null, @@ -77,7 +78,7 @@ class MainEventProcessorTest { whenever(scopes.options).thenReturn(sentryOptions) sentryTracer = SentryTracer(TransactionContext("", ""), scopes) - val hostnameCache = HostnameCache(hostnameCacheDuration) { getLocalhost } + val hostnameCache = HostnameCache({ getLocalhost }, hostnameCacheTicker) hostnameCacheMock.`when` { HostnameCache.getInstance() }.thenReturn(hostnameCache) return MainEventProcessor(sentryOptions) @@ -435,7 +436,7 @@ class MainEventProcessorTest { @Test fun `uses cache to retrieve servername for subsequent events`() { - val processor = fixture.getSut(serverName = null, host = "aHost", hostnameCacheDuration = 1000) + val processor = fixture.getSut(serverName = null, host = "aHost") val firstEvent = SentryEvent() processor.process(firstEvent, Hint()) assertEquals("aHost", firstEvent.serverName) @@ -454,12 +455,11 @@ class MainEventProcessorTest { reset(fixture.getLocalhost) whenever(fixture.getLocalhost.canonicalHostName).thenReturn("newHost") + fixture.hostnameCacheTicker.advance(6, TimeUnit.HOURS) - await.untilAsserted { - val secondEvent = SentryEvent() - processor.process(secondEvent, Hint()) - assertEquals("newHost", secondEvent.serverName) - } + val secondEvent = SentryEvent() + processor.process(secondEvent, Hint()) + assertEquals("newHost", secondEvent.serverName) } @Test