Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

- Removed SentryExecutorService limit for delayed scheduled tasks ([#4846](https://github.com/getsentry/sentry-java/pull/4846))
- Fix visual artifacts for the Canvas strategy on some devices ([#4861](https://github.com/getsentry/sentry-java/pull/4861))
- [ANR] Update Connection Status cache in the background ([#4832](https://github.com/getsentry/sentry-java/pull/4832))

### Improvements

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ public final class AndroidConnectionStatusProvider
private static final @NotNull AutoClosableReentrantLock childCallbacksLock =
new AutoClosableReentrantLock();
private static final @NotNull List<NetworkCallback> childCallbacks = new ArrayList<>();
private static final AtomicBoolean isUpdatingCache = new AtomicBoolean(false);

private static final int[] transports = {
NetworkCapabilities.TRANSPORT_WIFI,
Expand Down Expand Up @@ -146,6 +147,12 @@ private boolean isNetworkEffectivelyConnected(
: ConnectionStatus.DISCONNECTED;
}

// If cache is being updated, and there's no cached data, return UNKNOWN status until cache is
// updated
if (isUpdatingCache.get()) {
return ConnectionStatus.UNKNOWN;
}

// Fallback to legacy method when NetworkCapabilities not available
final ConnectivityManager connectivityManager =
getConnectivityManager(context, options.getLogger());
Expand All @@ -163,6 +170,12 @@ private boolean isNetworkEffectivelyConnected(
return getConnectionType(capabilities);
}

// If cache is being updated, and there's no cached data, return UNKNOWN status until cache is
// updated
if (isUpdatingCache.get()) {
return null;
}

// Fallback to legacy method when NetworkCapabilities not available
return getConnectionType(context, options.getLogger(), buildInfoProvider);
}
Expand Down Expand Up @@ -268,10 +281,19 @@ private void updateCacheAndNotifyObservers(

// Only notify observers if something meaningful changed
if (shouldUpdate) {
updateCache(networkCapabilities);

final @NotNull ConnectionStatus status = getConnectionStatusFromCache();
try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) {
cachedNetworkCapabilities = networkCapabilities;
lastCacheUpdateTime = timeProvider.getCurrentTimeMillis();
final @NotNull ConnectionStatus status = getConnectionStatusFromCache();
options
.getLogger()
.log(
SentryLevel.DEBUG,
"Cache updated - Status: "
+ status
+ ", Type: "
+ getConnectionTypeFromCache());

for (final @NotNull IConnectionStatusObserver observer :
connectionStatusObservers) {
observer.onConnectionStatusChanged(status);
Expand Down Expand Up @@ -349,78 +371,89 @@ private boolean hasSignificantTransportChanges(
}

@SuppressLint({"NewApi", "MissingPermission"})
private void updateCache(@Nullable NetworkCapabilities networkCapabilities) {
try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) {
try {
if (networkCapabilities != null) {
cachedNetworkCapabilities = networkCapabilities;
} else {
if (!Permissions.hasPermission(context, Manifest.permission.ACCESS_NETWORK_STATE)) {
options
.getLogger()
.log(
SentryLevel.INFO,
"No permission (ACCESS_NETWORK_STATE) to check network status.");
cachedNetworkCapabilities = null;
lastCacheUpdateTime = timeProvider.getCurrentTimeMillis();
return;
}
private void updateCache() {
try {
if (!Permissions.hasPermission(context, Manifest.permission.ACCESS_NETWORK_STATE)) {
options
.getLogger()
.log(SentryLevel.INFO, "No permission (ACCESS_NETWORK_STATE) to check network status.");
try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) {
cachedNetworkCapabilities = null;
lastCacheUpdateTime = timeProvider.getCurrentTimeMillis();
}
return;
}

if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.M) {
cachedNetworkCapabilities = null;
lastCacheUpdateTime = timeProvider.getCurrentTimeMillis();
return;
}
if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.M) {
try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) {
cachedNetworkCapabilities = null;
lastCacheUpdateTime = timeProvider.getCurrentTimeMillis();
}
return;
}

// Fallback: query current active network
final ConnectivityManager connectivityManager =
getConnectivityManager(context, options.getLogger());
if (connectivityManager != null) {
final Network activeNetwork = connectivityManager.getActiveNetwork();

cachedNetworkCapabilities =
activeNetwork != null
? connectivityManager.getNetworkCapabilities(activeNetwork)
: null;
} else {
cachedNetworkCapabilities =
null; // Clear cached capabilities if connectivity manager is null
}
// Avoid concurrent updates
if (!isUpdatingCache.getAndSet(true)) {
// Fallback: query current active network in the background
if (options.getThreadChecker().isMainThread()) {
submitSafe(() -> updateCacheFromConnectivityManager(), () -> isUpdatingCache.set(false));
} else {
updateCacheFromConnectivityManager();
}
lastCacheUpdateTime = timeProvider.getCurrentTimeMillis();
}

options
.getLogger()
.log(
SentryLevel.DEBUG,
"Cache updated - Status: "
+ getConnectionStatusFromCache()
+ ", Type: "
+ getConnectionTypeFromCache());
} catch (Throwable t) {
options.getLogger().log(SentryLevel.WARNING, "Failed to update connection status cache", t);
} catch (Throwable t) {
options.getLogger().log(SentryLevel.WARNING, "Failed to update connection status cache", t);
try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) {
cachedNetworkCapabilities = null;
lastCacheUpdateTime = timeProvider.getCurrentTimeMillis();
}
}
}

@RequiresApi(api = Build.VERSION_CODES.M)
private void updateCacheFromConnectivityManager() {
final ConnectivityManager connectivityManager =
getConnectivityManager(context, options.getLogger());
if (connectivityManager != null) {
final @Nullable NetworkCapabilities capabilities =
getNetworkCapabilities(connectivityManager);

try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) {
cachedNetworkCapabilities = capabilities;
lastCacheUpdateTime = timeProvider.getCurrentTimeMillis();

if (capabilities != null) {
options
.getLogger()
.log(
SentryLevel.DEBUG,
"Cache updated - Status: "
+ getConnectionStatusFromCache()
+ ", Type: "
+ getConnectionTypeFromCache());
}
}
}
isUpdatingCache.set(false);
}
Copy link

Choose a reason for hiding this comment

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

Bug: Infinite flag: caching functions return unknowns forever

If an exception occurs in updateCacheFromConnectivityManager() before reaching line 438, the isUpdatingCache flag remains true forever. This causes all subsequent calls to getConnectionStatus() and getConnectionType() to return UNKNOWN or null indefinitely. The flag should be reset in a finally block or the outer catch block should reset it.

Fix in Cursor Fix in Web


private boolean isCacheValid() {
return (timeProvider.getCurrentTimeMillis() - lastCacheUpdateTime) < CACHE_TTL_MS;
}

@Override
public @NotNull ConnectionStatus getConnectionStatus() {
if (!isCacheValid()) {
updateCache(null);
updateCache();
}
return getConnectionStatusFromCache();
}

@Override
Copy link

Choose a reason for hiding this comment

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

The refactored updateCache() method is now asynchronous (submits work to a background executor), but callers like getConnectionStatus() and getConnectionType() expect synchronous behavior. These methods call updateCache() when the cache is invalid, then immediately return getConnectionStatusFromCache() without waiting for the background task to complete. This means the returned value will still be based on stale cache since the background update hasn't had time to execute. Consider either (1) making the callers wait for the background task, (2) documenting this async behavior clearly, or (3) providing a synchronous fallback path for initial cache population.
Severity: MEDIUM

🤖 Prompt for AI Agent

Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location:
sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java#L427-L435

Potential issue: The refactored `updateCache()` method is now asynchronous (submits work
to a background executor), but callers like `getConnectionStatus()` and
`getConnectionType()` expect synchronous behavior. These methods call `updateCache()`
when the cache is invalid, then immediately return `getConnectionStatusFromCache()`
without waiting for the background task to complete. This means the returned value will
still be based on stale cache since the background update hasn't had time to execute.
Consider either (1) making the callers wait for the background task, (2) documenting
this async behavior clearly, or (3) providing a synchronous fallback path for initial
cache population.

Did we get this right? 👍 / 👎 to inform future reviews.

Copy link
Member

Choose a reason for hiding this comment

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

@stefanosiano this is my concern as well -- I think many things won't work as we expect them to with this change of behavior.

I think we should probably specifically solve this for Replay (and maybe continuous profiling?). We could reuse your isUpdatingCache flag, and then bail without acquiring the lock if it's true. Then when the cache is finally updated, it'd call onConnectionStatusChanged and notify replay/cont.profiling about the latest status. There's (potentially) a chance replay would read a stale status and continue recording for a short period of time, but I'm hoping this chance is small and wouldn't result in a bigger problem than the current number of ANRs caused by it. Wdyt?

Copy link
Member Author

Choose a reason for hiding this comment

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

@romtsn So you're suggesting to return UNKNOWN when the cache is being updated in the background, right?

Because I think the issue of stale cache status happens just when the app goes to foreground, as that's the only time where we don't have the network listener updating the cache directly.

So in updateCache() I can check the current thread, and if it's on main it schedules in the background, otherwise (or the first time the cache is updated) it executes it right away
This way the onForeground() will update the cache and after that call the observers with the new status

Copy link
Member

Choose a reason for hiding this comment

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

the last known cached status, and if it's not available then UNKNOWN. which may (or may not) be the correct one

Copy link
Member

Choose a reason for hiding this comment

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

So in updateCache() I can check the current thread, and if it's on main it schedules in the background, otherwise (or the first time the cache is updated) it executes it right away
This way the onForeground() will update the cache and after that call the observers with the new status

I think this sounds good, too. Just need to make sure we don't acquire the lock from resumeInternal (or getConnectionStatus() on the main thread)

public @Nullable String getConnectionType() {
if (!isCacheValid()) {
updateCache(null);
updateCache();
}
return getConnectionTypeFromCache();
}
Expand Down Expand Up @@ -490,7 +523,7 @@ public void onForeground() {
() -> {
// proactively update cache and notify observers on foreground to ensure connectivity
// state is not stale
updateCache(null);
updateCache();

final @NotNull ConnectionStatus status = getConnectionStatusFromCache();
if (status == ConnectionStatus.DISCONNECTED) {
Expand Down Expand Up @@ -575,6 +608,14 @@ public NetworkCapabilities getCachedNetworkCapabilities() {
}
}

@RequiresApi(Build.VERSION_CODES.M)
@SuppressLint("MissingPermission")
private static @Nullable NetworkCapabilities getNetworkCapabilities(
final @NotNull ConnectivityManager connectivityManager) {
final Network activeNetwork = connectivityManager.getActiveNetwork();
return activeNetwork != null ? connectivityManager.getNetworkCapabilities(activeNetwork) : null;
}

/**
* Check the connection type of the active network
*
Expand Down Expand Up @@ -603,14 +644,7 @@ public NetworkCapabilities getCachedNetworkCapabilities() {
boolean cellular = false;

if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.M) {

final Network activeNetwork = connectivityManager.getActiveNetwork();
if (activeNetwork == null) {
logger.log(SentryLevel.INFO, "Network is null and cannot check network status");
return null;
}
final NetworkCapabilities networkCapabilities =
connectivityManager.getNetworkCapabilities(activeNetwork);
final NetworkCapabilities networkCapabilities = getNetworkCapabilities(connectivityManager);
if (networkCapabilities == null) {
logger.log(SentryLevel.INFO, "NetworkCapabilities is null and cannot check network type");
return null;
Expand Down Expand Up @@ -820,12 +854,19 @@ public static List<NetworkCallback> getChildCallbacks() {
}

private void submitSafe(@NotNull Runnable r) {
submitSafe(r, null);
}

private void submitSafe(final @NotNull Runnable r, final @Nullable Runnable onError) {
try {
options.getExecutorService().submit(r);
} catch (Throwable e) {
options
.getLogger()
.log(SentryLevel.ERROR, "AndroidConnectionStatusProvider submit failed", e);
if (onError != null) {
onError.run();
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ import io.sentry.android.core.AppState
import io.sentry.android.core.BuildInfoProvider
import io.sentry.android.core.ContextUtils
import io.sentry.android.core.SystemEventsBreadcrumbsIntegration
import io.sentry.test.DeferredExecutorService
import io.sentry.test.ImmediateExecutorService
import io.sentry.transport.ICurrentDateProvider
import io.sentry.util.thread.IThreadChecker
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
Expand All @@ -38,6 +40,7 @@ import kotlin.test.assertTrue
import org.junit.runner.RunWith
import org.mockito.MockedStatic
import org.mockito.Mockito.mockStatic
import org.mockito.Mockito.never
import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.argThat
Expand Down Expand Up @@ -103,6 +106,7 @@ class AndroidConnectionStatusProviderTest {
options = SentryOptions()
options.setLogger(logger)
options.executorService = ImmediateExecutorService()
options.threadChecker = AndroidThreadChecker.getInstance()

// Reset current time for each test to ensure cache isolation
currentTime = 1000L
Expand All @@ -125,6 +129,7 @@ class AndroidConnectionStatusProviderTest {

@AfterTest
fun `tear down`() {
options.executorService = ImmediateExecutorService()
// clear the cache and ensure proper cleanup
connectionStatusProvider.close()
contextUtilsStaticMock.close()
Expand Down Expand Up @@ -205,6 +210,41 @@ class AndroidConnectionStatusProviderTest {
providerWithNullConnectivity.close()
}

@Test
fun `When cache is updating, return UNKNOWN for connectionStatus on main thread`() {
whenever(networkInfo.isConnected).thenReturn(true)
// When we are on the main thread
val mockThreadChecker = mock<IThreadChecker>()
options.threadChecker = mockThreadChecker
whenever(mockThreadChecker.isMainThread()).thenReturn(true)

// The update is done on the background
val executorService = DeferredExecutorService()
options.executorService = executorService

// Advance time beyond TTL (2 minutes)
currentTime += 2 * 60 * 1000L

// Connection status is unknown while we update the cache
assertEquals(
IConnectionStatusProvider.ConnectionStatus.UNKNOWN,
connectionStatusProvider.connectionStatus,
)

verify(connectivityManager, never()).activeNetworkInfo
verify(connectivityManager, never()).activeNetwork

// When background cache update is done
executorService.runAll()

// Connection status is updated
verify(connectivityManager).activeNetwork
assertEquals(
IConnectionStatusProvider.ConnectionStatus.CONNECTED,
connectionStatusProvider.connectionStatus,
)
}

@Test
fun `When there's no permission, return null for getConnectionType`() {
whenever(contextMock.checkPermission(any(), any(), any())).thenReturn(PERMISSION_DENIED)
Expand All @@ -219,6 +259,35 @@ class AndroidConnectionStatusProviderTest {
assertNull(connectionStatusProvider.connectionType)
}

@Test
fun `When cache is updating, return null for getConnectionType on main thread`() {
whenever(networkInfo.isConnected).thenReturn(true)
// When we are on the main thread
val mockThreadChecker = mock<IThreadChecker>()
options.threadChecker = mockThreadChecker
whenever(mockThreadChecker.isMainThread()).thenReturn(true)

// The update is done on the background
val executorService = DeferredExecutorService()
options.executorService = executorService

// Advance time beyond TTL (2 minutes)
currentTime += 2 * 60 * 1000L

// Connection type is null while we update the cache
assertNull(connectionStatusProvider.connectionType)

verify(connectivityManager, never()).activeNetworkInfo
verify(connectivityManager, never()).activeNetwork

// When background cache update is done
executorService.runAll()

// Connection type is updated
verify(connectivityManager).activeNetwork
assertNotNull(connectionStatusProvider.connectionType)
}

@Test
fun `When network capabilities are not available, return null for getConnectionType`() {
whenever(connectivityManager.getNetworkCapabilities(any())).thenReturn(null)
Expand Down
Loading