From 32834abd103b1873a63e4157d3c9f76ce3210281 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 11 Sep 2026 16:04:55 +0200 Subject: [PATCH 1/2] 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 | 17 ++++++++ 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/sentry/src/main/java/io/sentry/HostnameCache.java b/sentry/src/main/java/io/sentry/HostnameCache.java index 56cc0c2e84..427b90d16d 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; @@ -42,14 +45,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); @@ -74,22 +79,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. final @NotNull ThreadPoolExecutor executor = @@ -122,8 +139,7 @@ boolean isClosed() { */ @Nullable public String getHostname() { - if (expirationTimestamp < System.currentTimeMillis() - && updateRunning.compareAndSet(false, true)) { + if (cacheFreshUntil.hasPassed() && updateRunning.compareAndSet(false, true)) { updateCache(); } @@ -136,7 +152,7 @@ private void updateCache() { () -> { try { hostname = getLocalhost.call().getCanonicalHostName(); - expirationTimestamp = System.currentTimeMillis() + cacheDuration; + cacheFreshUntil = Deadline.after(ticker, cacheDurationMillis, TimeUnit.MILLISECONDS); } finally { updateRunning.set(false); } @@ -156,7 +172,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 3cc3a52aa2..9d38b592e6 100644 --- a/sentry/src/test/java/io/sentry/HostnameCacheTest.kt +++ b/sentry/src/test/java/io/sentry/HostnameCacheTest.kt @@ -2,6 +2,7 @@ package io.sentry import com.google.common.truth.Truth.assertThat import io.sentry.test.getProperty +import io.sentry.time.TestMonotonicTicker import java.net.InetAddress import java.util.concurrent.ThreadPoolExecutor import java.util.concurrent.TimeUnit @@ -23,6 +24,22 @@ 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 `worker thread times out while idle instead of staying alive`() { val cache = getSut() From 1742189cefa7bdd8d1cfc472f15f3c3208eca6fe Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 11 Sep 2026 16:06:53 +0200 Subject: [PATCH 2/2] changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09622493a2..9e17d0d9da 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.56.0 ### Behavioral Changes