Skip to content

Commit 25f6a44

Browse files
jushclaude
authored andcommitted
[maps-sdk] Fix correctness issues insafeSystemCallWithCallback (#15384)
### Summary - Removed `withTimeoutOrNull` timeout from the helper: `display?.refreshRate` is a plain blocking call with no suspension points, so coroutine cancellation is never checked while the IPC is in flight; additionally, `runInterruptible` would not help either as the native Binder layer ignores `Thread.interrupt()` - Fixed `CancellationException` being silently swallowed by `catch (e: Exception)`, which suppressed scope cancellation and logged a spurious error - Switched default dispatcher from `Dispatchers.Default` to `Dispatchers.IO`, which has an elastic thread pool suited for blocking I/O rather than a fixed CPU-bound pool - Replaced `safeSystemCallWithCallback` with a simpler `safeSystemCall` that returns `T` directly — the callback and `mainDispatcher` parameter were unnecessary since after `withContext(Dispatchers.IO)` returns the coroutine is already back on the caller's dispatcher ### Context The `safeSystemCallWithCallback` helper was introduced to move `display?.refreshRate` (a blocking Binder IPC) off the main thread. This PR addresses correctness issues in that helper discovered during review of MAPSAND-2266. ### Key Changes - **`Utils.kt`**: Removed `safeSystemCallWithCallback`; replaced with `safeSystemCall` returning `T` directly, with `withContext(Dispatchers.IO)` and explicit rethrow of `CancellationException` - **`MapView.kt`, `MapSurface.kt`**: Updated call sites to use `safeSystemCall` - **`SafeSystemCallTest`**: Removed timeout tests; added `CancellationException` rethrow coverage ### Validation - [x] ktlint and lint pass - [x] `SafeSystemCallTest`, `MapViewTest`, `MapSurfaceTest` pass cc @mapbox/maps-android --------- Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> GitOrigin-RevId: 61119c3e820e31b9eb5b34908f0c9390cb9b8a2b
1 parent 05c78b9 commit 25f6a44

5 files changed

Lines changed: 114 additions & 287 deletions

File tree

‎maps-sdk/src/main/java/com/mapbox/maps/MapSurface.kt‎

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -101,21 +101,16 @@ class MapSurface : MapPluginProviderDelegate, MapControllable {
101101
mapController.setScreenRefreshRate(MapView.DEFAULT_FPS)
102102
// Retrieve screen refresh rate off the main thread to prevent ANR
103103
mapController.lifecycleScope.launch {
104-
safeSystemCallWithCallback(
105-
fallback = MapView.DEFAULT_FPS,
106-
logTag = TAG,
107-
operation = {
108-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
109-
context.display.refreshRate.toInt()
110-
} else {
111-
@Suppress("DEPRECATION")
112-
(context.getSystemService(Context.WINDOW_SERVICE) as WindowManager?)
113-
?.defaultDisplay?.refreshRate?.toInt() ?: MapView.DEFAULT_FPS
114-
}
104+
val rate = safeBinderCall(fallback = MapView.DEFAULT_FPS, logTag = TAG) {
105+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
106+
context.display.refreshRate.toInt()
107+
} else {
108+
@Suppress("DEPRECATION")
109+
(context.getSystemService(Context.WINDOW_SERVICE) as WindowManager?)
110+
?.defaultDisplay?.refreshRate?.toInt() ?: MapView.DEFAULT_FPS
115111
}
116-
) { screenRefreshRate ->
117-
mapController.setScreenRefreshRate(screenRefreshRate)
118112
}
113+
mapController.setScreenRefreshRate(rate)
119114
}
120115

121116
// Stop any previous display refresh rate monitor in case `surfaceCreated` is called twice

‎maps-sdk/src/main/java/com/mapbox/maps/MapView.kt‎

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -255,15 +255,10 @@ open class MapView : FrameLayout, MapPluginProviderDelegate, MapControllable {
255255
mapController.setScreenRefreshRate(DEFAULT_FPS)
256256
// Retrieve screen refresh rate off the main thread to prevent ANR
257257
mapController.lifecycleScope.launch {
258-
safeSystemCallWithCallback(
259-
fallback = DEFAULT_FPS,
260-
logTag = TAG,
261-
operation = {
262-
display?.refreshRate?.toInt() ?: DEFAULT_FPS
263-
}
264-
) { screenRefreshRate ->
265-
mapController.setScreenRefreshRate(screenRefreshRate)
258+
val rate = safeBinderCall(fallback = DEFAULT_FPS, logTag = TAG) {
259+
display?.refreshRate?.toInt() ?: DEFAULT_FPS
266260
}
261+
mapController.setScreenRefreshRate(rate)
267262
}
268263

269264
// Stop any previous display refresh rate monitor in case `onStart` is called twice

‎maps-sdk/src/main/java/com/mapbox/maps/Utils.kt‎

Lines changed: 22 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ import android.content.Context
44
import android.util.TypedValue
55
import com.mapbox.bindgen.Expected
66
import com.mapbox.common.Cancelable
7+
import kotlinx.coroutines.CancellationException
78
import kotlinx.coroutines.CoroutineDispatcher
9+
import kotlinx.coroutines.CoroutineName
810
import kotlinx.coroutines.Dispatchers
911
import kotlinx.coroutines.suspendCancellableCoroutine
1012
import kotlinx.coroutines.withContext
11-
import kotlinx.coroutines.withTimeoutOrNull
1213
import java.lang.ref.WeakReference
1314
import kotlin.coroutines.Continuation
1415

@@ -40,63 +41,30 @@ internal fun <T : Number> T.toDP(context: Context): T {
4041
}
4142

4243
/**
43-
* Executes a potentially blocking system call off the main thread with timeout and error handling.
44-
* This utility helps prevent ANRs caused by blocking IPC calls to system services.
44+
* Executes a potentially blocking system call off the main thread, preventing ANRs.
4545
*
46-
* Uses [Dispatchers.Default] as system calls are CPU-bound operations involving IPC to system services.
46+
* The call runs on [dispatcher] (default [Dispatchers.IO]). Note: if the underlying
47+
* operation is a blocking Binder IPC (e.g. [android.view.Display.getRefreshRate]), its
48+
* duration cannot be reliably bounded — the native Binder layer ignores [Thread.interrupt],
49+
* so the dispatcher thread may stay blocked until the system server replies. The main thread
50+
* is never affected.
4751
*
48-
* @param timeoutMs Timeout in milliseconds for the operation (default: 5000ms)
49-
* @param fallback Fallback value to return if the operation fails or times out
50-
* @param logTag Tag for logging errors (default: "SystemCall")
51-
* @param dispatcher The dispatcher to use for the operation (default: Dispatchers.Default)
52-
* @param operation The blocking operation to execute
53-
* @return The result of the operation or the fallback value
52+
* @param fallback Value returned if the operation throws.
53+
* @param logTag Tag for error logging.
54+
* @param dispatcher Dispatcher for the blocking operation (default: [Dispatchers.IO]).
55+
* @param operation The blocking operation to execute.
56+
* @return The result of [operation], or [fallback] if it throws.
5457
*/
55-
internal suspend fun <T> safeSystemCall(
56-
timeoutMs: Long = 5000L,
58+
internal suspend fun <T> safeBinderCall(
5759
fallback: T,
5860
logTag: String = "SystemCall",
59-
dispatcher: CoroutineDispatcher = Dispatchers.Default,
60-
operation: suspend () -> T
61-
): T {
62-
return try {
63-
withTimeoutOrNull(timeoutMs) {
64-
withContext(dispatcher) {
65-
operation()
66-
}
67-
} ?: run {
68-
logW(logTag, "System call timed out after ${timeoutMs}ms, using fallback")
69-
fallback
70-
}
71-
} catch (e: Exception) {
72-
logE(logTag, "System call failed: ${e.message}, using fallback")
73-
fallback
74-
}
75-
}
76-
77-
/**
78-
* Executes a potentially blocking system call off the main thread and posts the result back to the main thread.
79-
* This is useful for updating UI components after retrieving system information.
80-
*
81-
* @param timeoutMs Timeout in milliseconds for the operation (default: 5000ms)
82-
* @param fallback Fallback value to return if the operation fails or times out
83-
* @param logTag Tag for logging errors (default: "SystemCall")
84-
* @param dispatcher The dispatcher to use for the operation (default: Dispatchers.Default)
85-
* @param mainDispatcher The dispatcher to use for the callback (default: Dispatchers.Main)
86-
* @param operation The blocking operation to execute
87-
* @param onResult Callback to handle the result on the main thread
88-
*/
89-
internal suspend fun <T> safeSystemCallWithCallback(
90-
timeoutMs: Long = 5000L,
91-
fallback: T,
92-
logTag: String = "SystemCall",
93-
dispatcher: CoroutineDispatcher = Dispatchers.Default,
94-
mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
61+
dispatcher: CoroutineDispatcher = Dispatchers.IO,
9562
operation: suspend () -> T,
96-
onResult: (T) -> Unit
97-
) {
98-
val result = safeSystemCall(timeoutMs, fallback, logTag, dispatcher, operation)
99-
withContext(mainDispatcher) {
100-
onResult(result)
101-
}
63+
): T = try {
64+
withContext(dispatcher + CoroutineName("safeBinderCall")) { operation() }
65+
} catch (e: CancellationException) {
66+
throw e
67+
} catch (e: Exception) {
68+
logE(logTag, "System call failed: ${e.message}, using fallback")
69+
fallback
10270
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package com.mapbox.maps
2+
3+
import com.mapbox.verifyOnce
4+
import io.mockk.Runs
5+
import io.mockk.every
6+
import io.mockk.just
7+
import io.mockk.mockkStatic
8+
import io.mockk.unmockkStatic
9+
import kotlinx.coroutines.CancellationException
10+
import kotlinx.coroutines.Dispatchers
11+
import kotlinx.coroutines.ExperimentalCoroutinesApi
12+
import kotlinx.coroutines.test.TestCoroutineScheduler
13+
import kotlinx.coroutines.test.UnconfinedTestDispatcher
14+
import kotlinx.coroutines.test.resetMain
15+
import kotlinx.coroutines.test.runTest
16+
import kotlinx.coroutines.test.setMain
17+
import org.junit.After
18+
import org.junit.Assert.assertEquals
19+
import org.junit.Assert.assertThrows
20+
import org.junit.Before
21+
import org.junit.Test
22+
import org.junit.runner.RunWith
23+
import org.robolectric.RobolectricTestRunner
24+
25+
@OptIn(ExperimentalCoroutinesApi::class)
26+
@RunWith(RobolectricTestRunner::class)
27+
class SafeBinderCallTest {
28+
29+
private val testScheduler = TestCoroutineScheduler()
30+
private val testDispatcher = UnconfinedTestDispatcher(testScheduler)
31+
32+
@Before
33+
fun setUp() {
34+
Dispatchers.setMain(testDispatcher)
35+
mockkStatic("com.mapbox.maps.MapboxLogger")
36+
every { logI(any(), any()) } just Runs
37+
every { logW(any(), any()) } just Runs
38+
every { logE(any(), any()) } just Runs
39+
}
40+
41+
@After
42+
fun cleanUp() {
43+
Dispatchers.resetMain()
44+
unmockkStatic("com.mapbox.maps.MapboxLogger")
45+
}
46+
47+
@Test
48+
fun `safeBinderCall should return operation result when successful`() = runTest {
49+
val result = safeBinderCall(fallback = 0, logTag = "Test", dispatcher = testDispatcher) { 42 }
50+
assertEquals(42, result)
51+
}
52+
53+
@Test
54+
fun `safeBinderCall should return fallback when operation throws`() = runTest {
55+
val result = safeBinderCall(fallback = 100, logTag = "Test", dispatcher = testDispatcher) {
56+
throw RuntimeException("Test exception")
57+
}
58+
assertEquals(100, result)
59+
verifyOnce { logE("Test", "System call failed: Test exception, using fallback") }
60+
}
61+
62+
@Test
63+
fun `safeBinderCall should rethrow CancellationException`() = runTest {
64+
var reached = false
65+
assertThrows(CancellationException::class.java) {
66+
kotlinx.coroutines.runBlocking {
67+
safeBinderCall(fallback = 0, logTag = "Test", dispatcher = testDispatcher) {
68+
throw CancellationException("scope cancelled")
69+
}
70+
reached = true
71+
}
72+
}
73+
assertEquals(false, reached)
74+
}
75+
76+
@Test
77+
fun `safeBinderCall should handle null fallback`() = runTest {
78+
val result = safeBinderCall<String?>(fallback = null, logTag = "Test", dispatcher = testDispatcher) { null }
79+
assertEquals(null, result)
80+
}
81+
}

0 commit comments

Comments
 (0)