From 425734b058bc7483f08ff39fd1daecacbc5ef041 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 15 Sep 2026 17:45:30 +0200 Subject: [PATCH 1/4] fix(core): Keep resolving the hostname after Sentry.close() MainEventProcessor was Closeable, so Scopes.close() closed it, and it shut down the process-wide HostnameCache singleton. Nothing ever replaced that singleton: INSTANCE is assigned once and never cleared, so a re-init handed the same shut-down cache to the new MainEventProcessor, and to MetricsApi and LoggerApi, which read it directly. The damage was silent and permanent. While the cache was still fresh, getHostname() kept returning the value it already had. On the first expiry after the close, getHostname() flipped updateRunning to true and then submit() threw RejectedExecutionException on the terminated executor. That is a RuntimeException, so it was swallowed into handleCacheUpdateFailure(), but the updateRunning reset lives in the submitted callable's finally block, which never ran. updateRunning stayed true, so the compareAndSet guard failed from then on and no refresh was ever attempted again. server_name froze at its last resolved value for the life of the process, with no exception and no log line. Nothing needs to close this cache. Its executor is a single daemon thread with allowCoreThreadTimeOut(true) and a 30 second keep-alive, so the worker exits on its own once idle and never holds up process exit; the thread exists for about 30 seconds out of every 5 hour refresh interval. Scopes.close() already leaves the timer executor running for exactly this reason. The one test that covered this path, SentryClientTest's `when client is closed, hostname cache is closed`, asserted isClosed() on a processor that had never resolved a hostname, where isClosed() returned true because the cache was still null. It never exercised the behavior it named. Replaced with an assertion that MainEventProcessor is not Closeable, which fails if the wiring comes back. Co-Authored-By: Claude Opus 5 (1M context) --- sentry/api/sentry.api | 3 +-- .../main/java/io/sentry/HostnameCache.java | 10 +------ .../java/io/sentry/MainEventProcessor.java | 26 +------------------ .../test/java/io/sentry/HostnameCacheTest.kt | 7 ----- .../java/io/sentry/MainEventProcessorTest.kt | 14 +++++----- .../test/java/io/sentry/SentryClientTest.kt | 10 ------- 6 files changed, 10 insertions(+), 60 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 24635fc5ddd..f28fffd6b80 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1392,9 +1392,8 @@ public abstract interface class io/sentry/JsonUnknown { public abstract fun setUnknown (Ljava/util/Map;)V } -public final class io/sentry/MainEventProcessor : io/sentry/EventProcessor, java/io/Closeable { +public final class io/sentry/MainEventProcessor : io/sentry/EventProcessor { public fun (Lio/sentry/SentryOptions;)V - public fun close ()V public fun getOrder ()Ljava/lang/Long; public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; public fun process (Lio/sentry/SentryLogEvent;)Lio/sentry/SentryLogEvent; diff --git a/sentry/src/main/java/io/sentry/HostnameCache.java b/sentry/src/main/java/io/sentry/HostnameCache.java index 56cc0c2e845..62494c73784 100644 --- a/sentry/src/main/java/io/sentry/HostnameCache.java +++ b/sentry/src/main/java/io/sentry/HostnameCache.java @@ -91,7 +91,7 @@ private HostnameCache() { this.cacheDuration = cacheDuration; this.getLocalhost = Objects.requireNonNull(getLocalhost, "getLocalhost is required"); // A single thread executor whose worker thread times out while idle, so no thread is kept - // alive between the infrequent cache refreshes. + // alive between the infrequent cache refreshes and nothing has to shut it down. final @NotNull ThreadPoolExecutor executor = new ThreadPoolExecutor( 1, @@ -105,14 +105,6 @@ private HostnameCache() { updateCache(); } - void close() { - this.executorService.shutdown(); - } - - boolean isClosed() { - return this.executorService.isShutdown(); - } - /** * Gets the hostname of the current machine. * diff --git a/sentry/src/main/java/io/sentry/MainEventProcessor.java b/sentry/src/main/java/io/sentry/MainEventProcessor.java index d84c9e47be8..bde148134a5 100644 --- a/sentry/src/main/java/io/sentry/MainEventProcessor.java +++ b/sentry/src/main/java/io/sentry/MainEventProcessor.java @@ -8,18 +8,15 @@ import io.sentry.protocol.SentryTransaction; import io.sentry.protocol.User; import io.sentry.util.HintUtils; -import java.io.Closeable; -import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Map; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.VisibleForTesting; @ApiStatus.Internal -public final class MainEventProcessor implements EventProcessor, Closeable { +public final class MainEventProcessor implements EventProcessor { private final @NotNull SentryOptions options; private final @NotNull SentryThreadFactory sentryThreadFactory; @@ -271,27 +268,6 @@ private boolean isCachedHint(final @NotNull Hint hint) { return HintUtils.hasType(hint, Cached.class); } - @Override - public void close() throws IOException { - if (hostnameCache != null) { - hostnameCache.close(); - } - } - - boolean isClosed() { - if (hostnameCache != null) { - return hostnameCache.isClosed(); - } else { - return true; - } - } - - @VisibleForTesting - @Nullable - HostnameCache getHostnameCache() { - return hostnameCache; - } - @Override public @Nullable Long getOrder() { return 0L; diff --git a/sentry/src/test/java/io/sentry/HostnameCacheTest.kt b/sentry/src/test/java/io/sentry/HostnameCacheTest.kt index 3cc3a52aa26..a6e3836a7cf 100644 --- a/sentry/src/test/java/io/sentry/HostnameCacheTest.kt +++ b/sentry/src/test/java/io/sentry/HostnameCacheTest.kt @@ -31,11 +31,4 @@ class HostnameCacheTest { assertThat(executorService.corePoolSize).isEqualTo(1) assertThat(executorService.maximumPoolSize).isEqualTo(1) } - - @Test - fun `close shuts the executor down`() { - val cache = getSut() - cache.close() - assertThat(cache.isClosed).isTrue() - } } diff --git a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt index fe5c835c90f..dc8002a376f 100644 --- a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt +++ b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt @@ -7,6 +7,7 @@ import io.sentry.protocol.SdkVersion import io.sentry.protocol.SentryTransaction import io.sentry.protocol.User import io.sentry.util.HintUtils +import java.io.Closeable import java.lang.RuntimeException import java.net.InetAddress import kotlin.test.AfterTest @@ -572,13 +573,12 @@ class MainEventProcessorTest { } @Test - fun `when processor is closed, closes hostname cache`() { - val sut = fixture.getSut(serverName = null) - - sut.process(SentryTransaction(fixture.sentryTracer), Hint()) - - sut.close() - assertNotNull(sut.hostnameCache) { assertTrue(it.isClosed) } + fun `is not Closeable, so closing the SDK cannot shut the hostname cache down`() { + // Scopes.close() closes every event processor that is Closeable. This one used to be, and + // closed the process-wide HostnameCache: its executor stayed terminated for the life of the + // process, updateRunning latched true so no refresh was ever retried, and serverName froze at + // whatever had been resolved last. + assertFalse(Closeable::class.java.isAssignableFrom(MainEventProcessor::class.java)) } @Test diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 3066d2c1b53..61181ee96a6 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -214,16 +214,6 @@ class SentryClientTest { assertFalse(sut.isEnabled) } - @Test - fun `when client is closed, hostname cache is closed`() { - val sut = fixture.getSut() - assertTrue(sut.isEnabled) - sut.close() - val mainEventProcessor = - fixture.sentryOptions.eventProcessors.filterIsInstance().first() - assertTrue(mainEventProcessor.isClosed) - } - @Test fun `when beforeSend is set, callback is invoked`() { var invoked = false From 1dbedb455c10f16959883a9fd65bab81039dbf1d Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 15 Sep 2026 17:46:35 +0200 Subject: [PATCH 2/4] changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a8eea2d9cf..a1b63b2e28d 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`) +### Fixes + +- Keep resolving the server name after `Sentry.close()` or a re-init. Closing the SDK shut down the shared hostname cache for the life of the process, so `server_name` silently froze at the value it had last resolved ([#6119](https://github.com/getsentry/sentry-java/pull/6119)) + ### Internal - Deprecate `AndroidCurrentDateProvider.getInstance()` in favor of `MonotonicTicker`, which counts time spent in deep sleep and cannot be confused with the epoch-based `CurrentDateProvider` ([#6103](https://github.com/getsentry/sentry-java/pull/6103)) From 47f577f78121a6bff3c060cb25d199cfa503a95e Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 15 Sep 2026 17:56:10 +0200 Subject: [PATCH 3/4] fix(core): Clear updateRunning when a refresh cannot be queued updateRunning is cleared in exactly one place, the submitted callable's finally block, so it is cleared if and only if the callable runs. Every failure from Future.get() leaves the callable running, so it still clears the flag itself. A failure from submit() does not: the callable was never queued, nothing clears the flag, and the compareAndSet guard in getHostname() then fails forever, so no refresh is ever attempted again. Removing MainEventProcessor's close() took away the only reachable way to make submit() throw, but the invariant was still wrong: a bounded queue, a shutdown added later, or a failure to start a thread would silently resurrect the same permanent freeze. Splitting submit() out of the try means the two cases can be told apart. Clearing the flag on a timeout or an interrupt as well would be wrong, since the callable is still running there and refreshes would pile up behind a slow lookup; MainEventProcessorTest's `sets servername to null if retrieving takes longer time` covers that path. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/main/java/io/sentry/HostnameCache.java | 16 +++++++++++++++- .../test/java/io/sentry/HostnameCacheTest.kt | 17 +++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/HostnameCache.java b/sentry/src/main/java/io/sentry/HostnameCache.java index 62494c73784..4e9e2bc4c0d 100644 --- a/sentry/src/main/java/io/sentry/HostnameCache.java +++ b/sentry/src/main/java/io/sentry/HostnameCache.java @@ -8,6 +8,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; @@ -136,8 +137,21 @@ private void updateCache() { return null; }; + final Future futureTask; + try { + futureTask = executorService.submit(hostRetriever); + } catch (RejectedExecutionException e) { + // updateRunning is cleared by the callable's finally block, which never runs if the callable + // was never queued. Clearing it here keeps a failure to queue from latching the flag on and + // silencing every later refresh. + updateRunning.set(false); + handleCacheUpdateFailure(); + return; + } + + // A timeout or interrupt below leaves the callable running, so it still clears updateRunning + // itself; doing it here as well would let refreshes pile up behind a slow lookup. try { - final Future futureTask = executorService.submit(hostRetriever); futureTask.get(GET_HOSTNAME_TIMEOUT, TimeUnit.MILLISECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); diff --git a/sentry/src/test/java/io/sentry/HostnameCacheTest.kt b/sentry/src/test/java/io/sentry/HostnameCacheTest.kt index a6e3836a7cf..6d0398858a3 100644 --- a/sentry/src/test/java/io/sentry/HostnameCacheTest.kt +++ b/sentry/src/test/java/io/sentry/HostnameCacheTest.kt @@ -2,9 +2,11 @@ package io.sentry import com.google.common.truth.Truth.assertThat import io.sentry.test.getProperty +import io.sentry.test.injectForField import java.net.InetAddress import java.util.concurrent.ThreadPoolExecutor import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean import kotlin.test.Test import org.mockito.kotlin.mock import org.mockito.kotlin.whenever @@ -23,6 +25,21 @@ class HostnameCacheTest { assertThat(cache.hostname).isEqualTo("myhost") } + @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. + cache.getProperty("executorService").shutdown() + cache.injectForField("expirationTimestamp", 0L) + + assertThat(cache.hostname).isEqualTo("myhost") + + // The callable never ran, so nothing else clears this flag; left set, it would fail the + // compareAndSet guard in getHostname() and no refresh would ever be attempted again. + assertThat(cache.getProperty("updateRunning").get()).isFalse() + } + @Test fun `worker thread times out while idle instead of staying alive`() { val cache = getSut() From e92a998195e39fa87464de6f61d8bcdf920c219f Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 15 Sep 2026 17:57:49 +0200 Subject: [PATCH 4/4] test(core): Drop the not-Closeable assertion It asserted a type relationship rather than behavior, which says nothing about whether the hostname keeps resolving. The behavior that matters is covered by HostnameCacheTest: `worker thread times out while idle instead of staying alive` guards the self-terminating executor that makes closing unnecessary, and `a refresh that cannot be queued does not stop later refreshes` guards the latch that turned a one-off failure into a permanent one. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/test/java/io/sentry/MainEventProcessorTest.kt | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt index dc8002a376f..a35f0e0e026 100644 --- a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt +++ b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt @@ -7,7 +7,6 @@ import io.sentry.protocol.SdkVersion import io.sentry.protocol.SentryTransaction import io.sentry.protocol.User import io.sentry.util.HintUtils -import java.io.Closeable import java.lang.RuntimeException import java.net.InetAddress import kotlin.test.AfterTest @@ -572,15 +571,6 @@ class MainEventProcessorTest { } } - @Test - fun `is not Closeable, so closing the SDK cannot shut the hostname cache down`() { - // Scopes.close() closes every event processor that is Closeable. This one used to be, and - // closed the process-wide HostnameCache: its executor stayed terminated for the life of the - // process, updateRunning latched true so no refresh was ever retried, and serverName froze at - // whatever had been resolved last. - assertFalse(Closeable::class.java.isAssignableFrom(MainEventProcessor::class.java)) - } - @Test fun `when event has modules, appends to them`() { val sut = fixture.getSut(modules = mapOf("group1:artifact1" to "2.0.0"))