Skip to content

Commit 56fb8ca

Browse files
runningcodeclaude
andauthored
fix(core): Keep resolving the hostname after Sentry.close() (#6119)
* 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) <noreply@anthropic.com> * changelog * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent bc5c4c9 commit 56fb8ca

7 files changed

Lines changed: 39 additions & 64 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@
3535
- 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))
3636
- 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`)
3737

38+
### Fixes
39+
40+
- 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))
41+
3842
### Internal
3943

4044
- 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))

‎sentry/api/sentry.api‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1392,9 +1392,8 @@ public abstract interface class io/sentry/JsonUnknown {
13921392
public abstract fun setUnknown (Ljava/util/Map;)V
13931393
}
13941394

1395-
public final class io/sentry/MainEventProcessor : io/sentry/EventProcessor, java/io/Closeable {
1395+
public final class io/sentry/MainEventProcessor : io/sentry/EventProcessor {
13961396
public fun <init> (Lio/sentry/SentryOptions;)V
1397-
public fun close ()V
13981397
public fun getOrder ()Ljava/lang/Long;
13991398
public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent;
14001399
public fun process (Lio/sentry/SentryLogEvent;)Lio/sentry/SentryLogEvent;

‎sentry/src/main/java/io/sentry/HostnameCache.java‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import java.util.concurrent.ExecutorService;
99
import java.util.concurrent.Future;
1010
import java.util.concurrent.LinkedBlockingQueue;
11+
import java.util.concurrent.RejectedExecutionException;
1112
import java.util.concurrent.ThreadFactory;
1213
import java.util.concurrent.ThreadPoolExecutor;
1314
import java.util.concurrent.TimeUnit;
@@ -91,7 +92,7 @@ private HostnameCache() {
9192
this.cacheDuration = cacheDuration;
9293
this.getLocalhost = Objects.requireNonNull(getLocalhost, "getLocalhost is required");
9394
// A single thread executor whose worker thread times out while idle, so no thread is kept
94-
// alive between the infrequent cache refreshes.
95+
// alive between the infrequent cache refreshes and nothing has to shut it down.
9596
final @NotNull ThreadPoolExecutor executor =
9697
new ThreadPoolExecutor(
9798
1,
@@ -105,14 +106,6 @@ private HostnameCache() {
105106
updateCache();
106107
}
107108

108-
void close() {
109-
this.executorService.shutdown();
110-
}
111-
112-
boolean isClosed() {
113-
return this.executorService.isShutdown();
114-
}
115-
116109
/**
117110
* Gets the hostname of the current machine.
118111
*
@@ -144,8 +137,21 @@ private void updateCache() {
144137
return null;
145138
};
146139

140+
final Future<Void> futureTask;
141+
try {
142+
futureTask = executorService.submit(hostRetriever);
143+
} catch (RejectedExecutionException e) {
144+
// updateRunning is cleared by the callable's finally block, which never runs if the callable
145+
// was never queued. Clearing it here keeps a failure to queue from latching the flag on and
146+
// silencing every later refresh.
147+
updateRunning.set(false);
148+
handleCacheUpdateFailure();
149+
return;
150+
}
151+
152+
// A timeout or interrupt below leaves the callable running, so it still clears updateRunning
153+
// itself; doing it here as well would let refreshes pile up behind a slow lookup.
147154
try {
148-
final Future<Void> futureTask = executorService.submit(hostRetriever);
149155
futureTask.get(GET_HOSTNAME_TIMEOUT, TimeUnit.MILLISECONDS);
150156
} catch (InterruptedException e) {
151157
Thread.currentThread().interrupt();

‎sentry/src/main/java/io/sentry/MainEventProcessor.java‎

Lines changed: 1 addition & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,15 @@
88
import io.sentry.protocol.SentryTransaction;
99
import io.sentry.protocol.User;
1010
import io.sentry.util.HintUtils;
11-
import java.io.Closeable;
12-
import java.io.IOException;
1311
import java.util.ArrayList;
1412
import java.util.List;
1513
import java.util.Map;
1614
import org.jetbrains.annotations.ApiStatus;
1715
import org.jetbrains.annotations.NotNull;
1816
import org.jetbrains.annotations.Nullable;
19-
import org.jetbrains.annotations.VisibleForTesting;
2017

2118
@ApiStatus.Internal
22-
public final class MainEventProcessor implements EventProcessor, Closeable {
19+
public final class MainEventProcessor implements EventProcessor {
2320

2421
private final @NotNull SentryOptions options;
2522
private final @NotNull SentryThreadFactory sentryThreadFactory;
@@ -271,27 +268,6 @@ private boolean isCachedHint(final @NotNull Hint hint) {
271268
return HintUtils.hasType(hint, Cached.class);
272269
}
273270

274-
@Override
275-
public void close() throws IOException {
276-
if (hostnameCache != null) {
277-
hostnameCache.close();
278-
}
279-
}
280-
281-
boolean isClosed() {
282-
if (hostnameCache != null) {
283-
return hostnameCache.isClosed();
284-
} else {
285-
return true;
286-
}
287-
}
288-
289-
@VisibleForTesting
290-
@Nullable
291-
HostnameCache getHostnameCache() {
292-
return hostnameCache;
293-
}
294-
295271
@Override
296272
public @Nullable Long getOrder() {
297273
return 0L;

‎sentry/src/test/java/io/sentry/HostnameCacheTest.kt‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ package io.sentry
22

33
import com.google.common.truth.Truth.assertThat
44
import io.sentry.test.getProperty
5+
import io.sentry.test.injectForField
56
import java.net.InetAddress
67
import java.util.concurrent.ThreadPoolExecutor
78
import java.util.concurrent.TimeUnit
9+
import java.util.concurrent.atomic.AtomicBoolean
810
import kotlin.test.Test
911
import org.mockito.kotlin.mock
1012
import org.mockito.kotlin.whenever
@@ -23,6 +25,21 @@ class HostnameCacheTest {
2325
assertThat(cache.hostname).isEqualTo("myhost")
2426
}
2527

28+
@Test
29+
fun `a refresh that cannot be queued does not stop later refreshes`() {
30+
val cache = getSut()
31+
// Reject the next submit the way an executor that could not start a thread would, and mark the
32+
// cache stale so that reading the hostname attempts a refresh.
33+
cache.getProperty<ThreadPoolExecutor>("executorService").shutdown()
34+
cache.injectForField("expirationTimestamp", 0L)
35+
36+
assertThat(cache.hostname).isEqualTo("myhost")
37+
38+
// The callable never ran, so nothing else clears this flag; left set, it would fail the
39+
// compareAndSet guard in getHostname() and no refresh would ever be attempted again.
40+
assertThat(cache.getProperty<AtomicBoolean>("updateRunning").get()).isFalse()
41+
}
42+
2643
@Test
2744
fun `worker thread times out while idle instead of staying alive`() {
2845
val cache = getSut()
@@ -31,11 +48,4 @@ class HostnameCacheTest {
3148
assertThat(executorService.corePoolSize).isEqualTo(1)
3249
assertThat(executorService.maximumPoolSize).isEqualTo(1)
3350
}
34-
35-
@Test
36-
fun `close shuts the executor down`() {
37-
val cache = getSut()
38-
cache.close()
39-
assertThat(cache.isClosed).isTrue()
40-
}
4151
}

‎sentry/src/test/java/io/sentry/MainEventProcessorTest.kt‎

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -571,16 +571,6 @@ class MainEventProcessorTest {
571571
}
572572
}
573573

574-
@Test
575-
fun `when processor is closed, closes hostname cache`() {
576-
val sut = fixture.getSut(serverName = null)
577-
578-
sut.process(SentryTransaction(fixture.sentryTracer), Hint())
579-
580-
sut.close()
581-
assertNotNull(sut.hostnameCache) { assertTrue(it.isClosed) }
582-
}
583-
584574
@Test
585575
fun `when event has modules, appends to them`() {
586576
val sut = fixture.getSut(modules = mapOf("group1:artifact1" to "2.0.0"))

‎sentry/src/test/java/io/sentry/SentryClientTest.kt‎

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -214,16 +214,6 @@ class SentryClientTest {
214214
assertFalse(sut.isEnabled)
215215
}
216216

217-
@Test
218-
fun `when client is closed, hostname cache is closed`() {
219-
val sut = fixture.getSut()
220-
assertTrue(sut.isEnabled)
221-
sut.close()
222-
val mainEventProcessor =
223-
fixture.sentryOptions.eventProcessors.filterIsInstance<MainEventProcessor>().first()
224-
assertTrue(mainEventProcessor.isClosed)
225-
}
226-
227217
@Test
228218
fun `when beforeSend is set, callback is invoked`() {
229219
var invoked = false

0 commit comments

Comments
 (0)