Skip to content

fix(core): Keep resolving the hostname after Sentry.close() - #6119

Open
runningcode wants to merge 4 commits into
mainfrom
no/hostname-cache-survives-sentry-close
Open

runningcode wants to merge 4 commits into
mainfrom
no/hostname-cache-survives-sentry-close

Conversation

@runningcode

@runningcode runningcode commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

📜 Description

So going deep in the weeds on the HostnameCache for the Clocks project made me realize we had a bug here. Yes we also have a bug in that we're using a wallclock to determine the hostnamecache timeout but that's a different PR.

The main issue here is that once you call close on the singleton HostnameCache, we can never look up another hostname again since the executor is shutdown. Since it is a singleton there's no way to restart it or create a new instance making it a permanent failure.

Here's a description of how this goes wrong:

  1. getHostname() flips updateRunning to true via compareAndSet and calls updateCache().
  2. executorService.submit(...) throws RejectedExecutionException on the terminated executor.
  3. That is a RuntimeException, so it is swallowed into handleCacheUpdateFailure().
  4. But the updateRunning.set(false) reset lives in the submitted callable's finally block — and the callable never ran.

You see there's a second bug in that updateRunning is never set to false on a RejectedExecutionException and then we never attempt another refresh.

The fix is that 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 roughly 30 seconds out of every 5-hour refresh interval. Scopes.close() already leaves the timer executor running for exactly this reason:

// On restart we intentionally leave the timer executor running ...
// It self-terminates once idle (allowCoreThreadTimeOut).

💡 Motivation and Context

Any Sentry.close() or re-init permanently stopped hostname refreshes for the whole process. Both paths go through Scopes.close(), which closes every Closeable event processor.

💚 How did you test it?

There are new unit tests here.

📝 Checklist

  • I added GH Issue ID & Linear ID
  • I added tests to verify the changes.
  • No new PII added or SDK only sends newly added PII if sendDefaultPII is enabled.
  • I updated the docs if needed.
  • I updated the wizard if needed.
  • Review from the native team if needed.
  • No breaking change or entry added to the changelog.
  • No breaking change for hybrid SDKs or communicated to hybrid SDKs.
  • Public API changes reviewed by another Mobile SDK team member or implemented according to the develop docs spec.

MainEventProcessor is @ApiStatus.Internal, so dropping Closeable and close() from it changes sentry.api without changing the public contract. The practical effect is that Scopes.close()'s "close any Closeable event processor" loop now skips it, which is the fix.

🔮 Next steps

None.

🤖 Generated with Claude Code

runningcode and others added 2 commits September 15, 2026 17:45
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>
@sentry

sentry Bot commented Sep 15, 2026

Copy link
Copy Markdown

📲 Install Builds

Android

🔗 App Name App ID Version Configuration
SDK Size io.sentry.tests.size 8.56.0 (1) release

⚙️ sentry-android Build Distribution Settings

runningcode and others added 2 commits September 15, 2026 17:56
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>
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>
@runningcode
runningcode marked this pull request as ready for review September 15, 2026 16:10
@runningcode runningcode added the sanity-check PR needs a lightweight review for obvious issues label Sep 15, 2026
// itself; doing it here as well would let refreshes pile up behind a slow lookup.
try {
final Future<Void> futureTask = executorService.submit(hostRetriever);
futureTask.get(GET_HOSTNAME_TIMEOUT, TimeUnit.MILLISECONDS);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this blocks for 1 second which IMO defeats the whole purpose of using an executor. im leaving this out of scope of this PR though. curious if others have thoughts on why this is.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

sanity-check PR needs a lightweight review for obvious issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant