Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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 @@ -21,6 +21,7 @@
}
```
- Fix abstract method error in `SentrySupportSQLiteDatabase` ([#4597](https://github.com/getsentry/sentry-java/pull/4597))
- Ensure frame metrics listeners are registered/unregistered on the main thread ([#4582](https://github.com/getsentry/sentry-java/pull/4582))

## 8.18.0

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@
import android.view.Window;
import androidx.annotation.RequiresApi;
import io.sentry.ILogger;
import io.sentry.ISentryLifecycleToken;
import io.sentry.SentryLevel;
import io.sentry.SentryOptions;
import io.sentry.SentryUUID;
import io.sentry.android.core.BuildInfoProvider;
import io.sentry.android.core.ContextUtils;
import io.sentry.util.AutoClosableReentrantLock;
import io.sentry.util.Objects;
import java.lang.ref.WeakReference;
import java.lang.reflect.Field;
Expand All @@ -38,6 +40,8 @@ public final class SentryFrameMetricsCollector implements Application.ActivityLi

private final @NotNull BuildInfoProvider buildInfoProvider;
private final @NotNull Set<Window> trackedWindows = new CopyOnWriteArraySet<>();
private final @NotNull AutoClosableReentrantLock trackedWindowsLock =
new AutoClosableReentrantLock();
private final @NotNull ILogger logger;
private @Nullable Handler handler;
private @Nullable WeakReference<Window> currentWindow;
Expand Down Expand Up @@ -282,17 +286,24 @@ public void stopCollection(final @Nullable String listenerId) {

@SuppressLint("NewApi")
private void stopTrackingWindow(final @NotNull Window window) {
if (trackedWindows.contains(window)) {
if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.N) {
try {
windowFrameMetricsManager.removeOnFrameMetricsAvailableListener(
window, frameMetricsAvailableListener);
} catch (Exception e) {
logger.log(SentryLevel.ERROR, "Failed to remove frameMetricsAvailableListener", e);
}
}
trackedWindows.remove(window);
}
new Handler(Looper.getMainLooper())
.post(
() -> {
try {
// Re-check if we should still remove the listener for this window
// in case trackCurrentWindow was called in the meantime
final boolean shouldRemove;
try (final @NotNull ISentryLifecycleToken ignored = trackedWindowsLock.acquire()) {
Copy link
Member

Choose a reason for hiding this comment

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

do we actually need this lock considering we only add/remove windows on the main thread now?

Copy link
Member Author

Choose a reason for hiding this comment

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

Oh true, that's some leftover, trackedWindows is only accessed within the main thread - thanks!

shouldRemove = trackedWindows.contains(window) && trackedWindows.remove(window);
Copy link
Member

Choose a reason for hiding this comment

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

I think should be enough to just call remove because it will return false if the set did not contain the element?
image

Suggested change
shouldRemove = trackedWindows.contains(window) && trackedWindows.remove(window);
shouldRemove = trackedWindows.remove(window);

}
if (shouldRemove) {
windowFrameMetricsManager.removeOnFrameMetricsAvailableListener(
window, frameMetricsAvailableListener);
}
} catch (Throwable e) {
logger.log(SentryLevel.ERROR, "Failed to remove frameMetricsAvailableListener", e);
}
});
}

private void setCurrentWindow(final @NotNull Window window) {
Expand All @@ -305,18 +316,35 @@ private void setCurrentWindow(final @NotNull Window window) {

@SuppressLint("NewApi")
private void trackCurrentWindow() {
Window window = currentWindow != null ? currentWindow.get() : null;
@Nullable Window window = currentWindow != null ? currentWindow.get() : null;
if (window == null || !isAvailable) {
return;
}

if (!trackedWindows.contains(window) && !listenerMap.isEmpty()) {
if (listenerMap.isEmpty()) {
return;
}

if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.N && handler != null) {
trackedWindows.add(window);
windowFrameMetricsManager.addOnFrameMetricsAvailableListener(
window, frameMetricsAvailableListener, handler);
}
if (handler != null) {
// Ensure the addOnFrameMetricsAvailableListener is called on the main thread
new Handler(Looper.getMainLooper())
.post(
() -> {
// Re-check if we should still track this window
// in case stopTrackingWindow was called for the same Window in the meantime
final boolean shouldAdd;
try (final @NotNull ISentryLifecycleToken ignored = trackedWindowsLock.acquire()) {
shouldAdd = !trackedWindows.contains(window) && trackedWindows.add(window);
Copy link
Member

Choose a reason for hiding this comment

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

same here I guess, could omit contains since it's a set?

}
if (shouldAdd) {
try {
windowFrameMetricsManager.addOnFrameMetricsAvailableListener(
window, frameMetricsAvailableListener, handler);
} catch (Throwable e) {
logger.log(SentryLevel.ERROR, "Failed to add frameMetricsAvailableListener", e);
}
}
});
Copy link

Choose a reason for hiding this comment

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

Bug: SentryFrameMetricsCollector: Race Conditions, Resource Leaks, Error Handling

The SentryFrameMetricsCollector has several issues:

  • Race Conditions:
    • A race between stopTrackingWindow() and trackCurrentWindow() can lead to windows being incorrectly untracked (missing frame metrics) if their main looper-posted operations execute out of order during rapid activity state changes.
    • In trackCurrentWindow(), listenerMap.isEmpty() is not re-checked inside the posted task, potentially adding a listener unnecessarily and causing a temporary resource leak.
  • Defensive Programming: Explicit API level checks for Build.VERSION_CODES.N were removed from stopTrackingWindow() and trackCurrentWindow(), reducing code robustness.
  • Error Handling: trackCurrentWindow()'s try-catch block does not cover trackedWindowsLock.acquire(), meaning lock acquisition failures would crash the app instead of being logged.
Fix in Cursor Fix in Web

}
}

Expand Down Expand Up @@ -373,6 +401,9 @@ default void addOnFrameMetricsAvailableListener(
final @NotNull Window window,
final @Nullable Window.OnFrameMetricsAvailableListener frameMetricsAvailableListener,
final @Nullable Handler handler) {
if (frameMetricsAvailableListener == null) {
Copy link
Member

Choose a reason for hiding this comment

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

probably also worth to add this check to removeOnFrameMetricsAvailableListener?

return;
}
window.addOnFrameMetricsAvailableListener(frameMetricsAvailableListener, handler);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ class SentryFrameMetricsCollectorTest {
collector.startCollection(mock())
assertEquals(0, fixture.addOnFrameMetricsAvailableListenerCounter)
collector.onActivityStarted(fixture.activity)
// Execute pending main looper tasks since addOnFrameMetricsAvailableListener is posted to main
// thread
Shadows.shadowOf(Looper.getMainLooper()).idle()
assertEquals(1, fixture.addOnFrameMetricsAvailableListenerCounter)
}

Expand All @@ -157,8 +160,12 @@ class SentryFrameMetricsCollectorTest {

collector.startCollection(mock())
collector.onActivityStarted(fixture.activity)
// Execute pending add operations
Shadows.shadowOf(Looper.getMainLooper()).idle()
assertEquals(0, fixture.removeOnFrameMetricsAvailableListenerCounter)
collector.onActivityStopped(fixture.activity)
// Execute pending remove operations
Shadows.shadowOf(Looper.getMainLooper()).idle()
assertEquals(1, fixture.removeOnFrameMetricsAvailableListenerCounter)
}

Expand All @@ -181,6 +188,8 @@ class SentryFrameMetricsCollectorTest {
collector.onActivityStarted(fixture.activity)
assertEquals(0, fixture.addOnFrameMetricsAvailableListenerCounter)
collector.startCollection(mock())
// Execute pending main looper tasks
Shadows.shadowOf(Looper.getMainLooper()).idle()
assertEquals(1, fixture.addOnFrameMetricsAvailableListenerCounter)
}

Expand All @@ -189,9 +198,13 @@ class SentryFrameMetricsCollectorTest {
val collector = fixture.getSut(context)
val id = collector.startCollection(mock())
collector.onActivityStarted(fixture.activity)
// Execute pending add operations
Shadows.shadowOf(Looper.getMainLooper()).idle()

assertEquals(0, fixture.removeOnFrameMetricsAvailableListenerCounter)
collector.stopCollection(id)
// Execute pending remove operations
Shadows.shadowOf(Looper.getMainLooper()).idle()
assertEquals(1, fixture.removeOnFrameMetricsAvailableListenerCounter)
}

Expand All @@ -205,9 +218,13 @@ class SentryFrameMetricsCollectorTest {

collector.onActivityStarted(fixture.activity)
collector.onActivityStarted(fixture.activity)
// Execute pending add operations
Shadows.shadowOf(Looper.getMainLooper()).idle()

collector.onActivityStopped(fixture.activity)
collector.onActivityStopped(fixture.activity)
// Execute pending remove operations
Shadows.shadowOf(Looper.getMainLooper()).idle()

assertEquals(1, fixture.addOnFrameMetricsAvailableListenerCounter)
assertEquals(1, fixture.removeOnFrameMetricsAvailableListenerCounter)
Expand All @@ -228,9 +245,13 @@ class SentryFrameMetricsCollectorTest {
collector.startCollection(mock())
collector.onActivityStarted(fixture.activity)
collector.onActivityStarted(fixture.activity2)
// Execute pending add operations
Shadows.shadowOf(Looper.getMainLooper()).idle()
assertEquals(2, fixture.addOnFrameMetricsAvailableListenerCounter)
collector.onActivityStopped(fixture.activity)
collector.onActivityStopped(fixture.activity2)
// Execute pending remove operations
Shadows.shadowOf(Looper.getMainLooper()).idle()
assertEquals(2, fixture.removeOnFrameMetricsAvailableListenerCounter)
}

Expand All @@ -240,10 +261,13 @@ class SentryFrameMetricsCollectorTest {
val id1 = collector.startCollection(mock())
val id2 = collector.startCollection(mock())
collector.onActivityStarted(fixture.activity)
Shadows.shadowOf(Looper.getMainLooper()).idle()
assertEquals(1, fixture.addOnFrameMetricsAvailableListenerCounter)
collector.stopCollection(id1)
Shadows.shadowOf(Looper.getMainLooper()).idle()
assertEquals(0, fixture.removeOnFrameMetricsAvailableListenerCounter)
collector.stopCollection(id2)
Shadows.shadowOf(Looper.getMainLooper()).idle()
assertEquals(1, fixture.removeOnFrameMetricsAvailableListenerCounter)
}

Expand Down Expand Up @@ -511,6 +535,48 @@ class SentryFrameMetricsCollectorTest {
)
}

@Test
fun `collector calls addOnFrameMetricsAvailableListener on main thread`() {
val collector = fixture.getSut(context)

collector.startCollection(mock())
collector.onActivityStarted(fixture.activity)

assertEquals(0, fixture.addOnFrameMetricsAvailableListenerCounter)
Shadows.shadowOf(Looper.getMainLooper()).idle()
assertEquals(1, fixture.addOnFrameMetricsAvailableListenerCounter)
}

@Test
fun `collector calls removeOnFrameMetricsAvailableListener on main thread`() {
val collector = fixture.getSut(context)
collector.startCollection(mock())
collector.onActivityStarted(fixture.activity)
Shadows.shadowOf(Looper.getMainLooper()).idle()

collector.onActivityStopped(fixture.activity)
assertEquals(0, fixture.removeOnFrameMetricsAvailableListenerCounter)
Shadows.shadowOf(Looper.getMainLooper()).idle()
assertEquals(1, fixture.removeOnFrameMetricsAvailableListenerCounter)
}

@Test
fun `collector prevents race condition when stop is called immediately after start`() {
val collector = fixture.getSut(context)

collector.startCollection(mock())
collector.onActivityStarted(fixture.activity)
collector.onActivityStopped(fixture.activity)

// Now execute all pending operations
Shadows.shadowOf(Looper.getMainLooper()).idle()

// as the listeners are posted to the main thread, we expect an add followed by a remove
assertEquals(1, fixture.addOnFrameMetricsAvailableListenerCounter)
assertEquals(1, fixture.removeOnFrameMetricsAvailableListenerCounter)
assertEquals(0, collector.getProperty<Set<Window>>("trackedWindows").size)
Copy link

Copilot AI Jul 31, 2025

Choose a reason for hiding this comment

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

Using reflection to access private property 'trackedWindows' with a magic string makes the test brittle. Consider adding a package-private getter method or using a test-specific interface to access this state.

Suggested change
assertEquals(0, collector.getProperty<Set<Window>>("trackedWindows").size)
assertEquals(0, collector.trackedWindows.size)

Copilot uses AI. Check for mistakes.

}

private fun createMockWindow(refreshRate: Float = 60F): Window {
val mockWindow = mock<Window>()
val mockDisplay = mock<Display>()
Expand Down
Loading