-
-
Notifications
You must be signed in to change notification settings - Fork 459
fix(android): Ensure frame metrics listeners are registered/unregistered on the main thread #4582
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 6 commits
c75be29
e1e6946
7f5590e
221e9db
4f2da73
489cf5c
2048699
2c0ef48
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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; | ||
|
@@ -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; | ||
|
@@ -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()) { | ||
shouldRemove = trackedWindows.contains(window) && 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) { | ||
|
@@ -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); | ||
|
||
} | ||
if (shouldAdd) { | ||
try { | ||
windowFrameMetricsManager.addOnFrameMetricsAvailableListener( | ||
window, frameMetricsAvailableListener, handler); | ||
} catch (Throwable e) { | ||
markushi marked this conversation as resolved.
Show resolved
Hide resolved
|
||
logger.log(SentryLevel.ERROR, "Failed to add frameMetricsAvailableListener", e); | ||
} | ||
} | ||
}); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: SentryFrameMetricsCollector: Race Conditions, Resource Leaks, Error HandlingThe
|
||
} | ||
} | ||
|
||
|
@@ -373,6 +401,9 @@ default void addOnFrameMetricsAvailableListener( | |
final @NotNull Window window, | ||
final @Nullable Window.OnFrameMetricsAvailableListener frameMetricsAvailableListener, | ||
final @Nullable Handler handler) { | ||
if (frameMetricsAvailableListener == null) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. probably also worth to add this check to |
||
return; | ||
} | ||
window.addOnFrameMetricsAvailableListener(frameMetricsAvailableListener, handler); | ||
} | ||
|
||
|
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -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) | ||||||
} | ||||||
|
||||||
|
@@ -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) | ||||||
} | ||||||
|
||||||
|
@@ -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) | ||||||
} | ||||||
|
||||||
|
@@ -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) | ||||||
} | ||||||
|
||||||
|
@@ -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) | ||||||
|
@@ -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) | ||||||
} | ||||||
|
||||||
|
@@ -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) | ||||||
} | ||||||
|
||||||
|
@@ -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) | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||
} | ||||||
|
||||||
private fun createMockWindow(refreshRate: Float = 60F): Window { | ||||||
val mockWindow = mock<Window>() | ||||||
val mockDisplay = mock<Display>() | ||||||
|
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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!