Skip to content

Commit 6c71ddd

Browse files
bmanderclaude
andauthored
Forward CancellationException across the api/repository layer (#1928)
Audit follow-up to the runCatching cancellation fix in #1921. `runCatching` (and a broad `catch (Exception/Throwable/RuntimeException/IllegalStateException)`) swallow kotlinx `CancellationException` — it's a plain `IllegalStateException` subtype — so a coroutine cancelled mid-suspend-call had its cancellation converted into a `Result.failure`/`success(null)`/empty value instead of propagating. That breaks structured concurrency: cancelled work keeps running and callers see a cancellation as an ordinary error/empty state. Swept the codebase; narrow catches (IOException, JSONException, SecurityException, IllegalArgumentException, …) don't catch cancellation and were left alone. Fixed every site where a swallowing construct wrapped a real suspend call in a cancellable coroutine by rethrowing CancellationException before swallowing — matching the existing idiom in RouteMapController/SingleFlight: - ObaApiProvider.call/callOrNull — the core wrapper behind ~every OBA network data source (highest leverage). Both layers needed the guard: callers wrap this in their own runCatching, so fixing only one layer re-swallows one level up. - SurveyDataSource.studies/submit, WeatherRepository.currentForecast, RouteSearchRepository.search — plain suspend funs returning the swallowed value. - BikeStationsRepository — outer runCatching plus an inner `catch (Exception)` that retried a second network fetch after cancellation (issued new work on the other URL structure after the coroutine was already cancelled). - TripInfoRepository.requestAlarm — a cancelled reminder save surfaced to the UI as TripInfoEvent.SaveFailed. - RouteInfoRepository, StopSearchRepository, SearchResultsRepository, MyListRepository — mitigated today by an enclosing withContext/coroutineScope prompt-cancellation guarantee, but fragile; guarded at the source. RouteInfo's recoverCatching had additionally disguised a cancellation as a "route not found" error string. Added SurveyDataSourceCancellationTest (JVM-testable; its cancellation path rethrows before touching android.util.Log). The Android-typed repos aren't directly unit-testable in this module (no Robolectric/mocking), same constraint SingleFlightTest documents. Not changed: ReminderClient (never-cancelled fire-and-forget scope), RegionsClient (non-suspend runBlocking bridges), MyListViewModel's Flow.catch (cancellation- transparent), and the many narrow catches. FocusedTripRepository's route-stops swallow is fixed separately in #1921 (getOrThrow). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c80aae3 commit 6c71ddd

11 files changed

Lines changed: 137 additions & 13 deletions

File tree

onebusaway-android/src/main/java/org/onebusaway/android/api/data/SurveyDataSource.kt

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package org.onebusaway.android.api.data
1717

1818
import android.util.Log
1919
import javax.inject.Inject
20+
import kotlinx.coroutines.CancellationException
2021
import org.onebusaway.android.api.contract.StudyResponse
2122
import org.onebusaway.android.api.contract.SurveyWebService
2223
import org.onebusaway.android.models.Survey
@@ -53,7 +54,10 @@ class DefaultSurveyDataSource @Inject constructor(
5354

5455
override suspend fun studies(url: String, userId: String?): Result<List<Survey>> = runCatching {
5556
service.getStudy(url, userId).toSurveys()
56-
}.onFailure { Log.e(TAG, "studies failed", it) }
57+
}.onFailure {
58+
if (it is CancellationException) throw it
59+
Log.e(TAG, "studies failed", it)
60+
}
5761

5862
override suspend fun submit(
5963
url: String,
@@ -73,7 +77,10 @@ class DefaultSurveyDataSource @Inject constructor(
7377
stopLongitude = stopLongitude,
7478
responses = responses,
7579
).let { SurveySubmitResult(it.surveyResponse?.id) }
76-
}.onFailure { Log.e(TAG, "submit failed", it) }
80+
}.onFailure {
81+
if (it is CancellationException) throw it
82+
Log.e(TAG, "submit failed", it)
83+
}
7784

7885
private companion object {
7986
const val TAG = "SurveyDataSource"

onebusaway-android/src/main/java/org/onebusaway/android/api/net/ObaApiProvider.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import android.net.Uri
1919
import java.io.IOException
2020
import javax.inject.Inject
2121
import javax.inject.Singleton
22+
import kotlinx.coroutines.CancellationException
2223
import kotlinx.serialization.json.Json
2324
import okhttp3.MediaType.Companion.toMediaType
2425
import okhttp3.OkHttpClient
@@ -52,7 +53,9 @@ class ObaApiProvider @Inject constructor(
5253
*/
5354
suspend fun <T> call(block: suspend (ObaWebService) -> T): Result<T> {
5455
val service = service() ?: return Result.failure(noEndpoint())
55-
return runCatching { block(service) }
56+
// runCatching catches everything, so a cancelled coroutine's CancellationException would become
57+
// a Result.failure and stop propagating; rethrow it so cancellation still unwinds normally.
58+
return runCatching { block(service) }.onFailure { if (it is CancellationException) throw it }
5659
}
5760

5861
/**
@@ -61,7 +64,7 @@ class ObaApiProvider @Inject constructor(
6164
*/
6265
suspend fun <T> callOrNull(block: suspend (ObaWebService) -> T): Result<T?> {
6366
val service = service() ?: return Result.success(null)
64-
return runCatching { block(service) }
67+
return runCatching { block(service) }.onFailure { if (it is CancellationException) throw it }
6568
}
6669

6770
/**

onebusaway-android/src/main/java/org/onebusaway/android/map/bike/BikeStationsRepository.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package org.onebusaway.android.map.bike
1717

1818
import javax.inject.Inject
1919
import android.location.Location
20+
import kotlinx.coroutines.CancellationException
2021
import org.onebusaway.android.R
2122
import org.onebusaway.android.api.adapters.toBikeStations
2223
import org.onebusaway.android.api.contract.BikeWebService
@@ -54,12 +55,15 @@ class DefaultBikeStationsRepository @Inject constructor(
5455
val useOld = prefs.getBoolean(R.string.preference_key_otp_api_url_version, false)
5556
try {
5657
fetch(base, useOld, southWest, northEast)
58+
} catch (e: CancellationException) {
59+
// Don't treat a cancelled fetch as a "wrong URL structure" and retry it on the other path.
60+
throw e
5761
} catch (e: Exception) {
5862
if (useOld) throw e
5963
fetch(base, useOldUrlStructure = true, southWest, northEast)
6064
.also { prefs.setBoolean(R.string.preference_key_otp_api_url_version, true) }
6165
}
62-
}
66+
}.onFailure { if (it is CancellationException) throw it }
6367

6468
private suspend fun fetch(
6569
base: String,

onebusaway-android/src/main/java/org/onebusaway/android/ui/home/weather/WeatherRepository.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import android.content.Context
1919
import dagger.hilt.android.qualifiers.ApplicationContext
2020
import java.io.IOException
2121
import javax.inject.Inject
22+
import kotlinx.coroutines.CancellationException
2223
import org.onebusaway.android.R
2324
import org.onebusaway.android.api.contract.WeatherWebService
2425
import org.onebusaway.android.region.RegionRepository
@@ -56,5 +57,5 @@ class DefaultWeatherRepository @Inject constructor(
5657
val forecast = weatherService.getWeather(url).current_forecast
5758
?: throw IOException("No weather forecast for region $regionId")
5859
WeatherData(forecast.icon ?: "", forecast.temperature, forecast.summary)
59-
}
60+
}.onFailure { if (it is CancellationException) throw it }
6061
}

onebusaway-android/src/main/java/org/onebusaway/android/ui/mylists/MyListRepository.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import android.text.format.DateUtils
2020
import androidx.annotation.ArrayRes
2121
import androidx.annotation.ColorRes
2222
import androidx.annotation.StringRes
23+
import kotlinx.coroutines.CancellationException
2324
import kotlinx.coroutines.Dispatchers
2425
import kotlinx.coroutines.ExperimentalCoroutinesApi
2526
import kotlinx.coroutines.async
@@ -389,7 +390,10 @@ private suspend fun fetchStopBadges(context: Context, stopId: String): List<Arri
389390
convertArrivals(context, snapshot.arrivals, ServerTime(snapshot.currentTime), false)
390391
.take(MAX_ARRIVALS_PER_STOP)
391392
.map { it.toBadge(context) }
392-
}.getOrDefault(emptyList())
393+
}.getOrElse {
394+
if (it is CancellationException) throw it
395+
emptyList()
396+
}
393397

394398
private fun ArrivalInfo.toBadge(context: Context): ArrivalBadge {
395399
val etaText = if (eta <= 0) {

onebusaway-android/src/main/java/org/onebusaway/android/ui/routeinfo/RouteInfoRepository.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import android.util.Log
2323
import dagger.hilt.android.qualifiers.ApplicationContext
2424
import java.io.IOException
2525
import javax.inject.Inject
26+
import kotlinx.coroutines.CancellationException
2627
import kotlinx.coroutines.Dispatchers
2728
import kotlinx.coroutines.async
2829
import kotlinx.coroutines.coroutineScope
@@ -75,6 +76,9 @@ class DefaultRouteInfoRepository @Inject constructor(
7576
registerRouteUsage(route)
7677
toRouteInfo(route, directions)
7778
}
79+
// Let a cancelled load unwind instead of logging it and disguising it (below) as a
80+
// "route not found" error string.
81+
.onFailure { if (it is CancellationException) throw it }
7882
.onFailure { Log.e(TAG, "loadRouteInfo($routeId) failed", it) }
7983
// The VM renders Result.failure's message, so surface a user-facing route error
8084
// string. Preserve the OBA code when we have one (e.g. 404 -> "route not found");

onebusaway-android/src/main/java/org/onebusaway/android/ui/search/RouteSearchRepository.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import org.onebusaway.android.api.data.LocationSearchDataSource
1919

2020
import android.util.Log
2121
import java.io.IOException
22+
import kotlinx.coroutines.CancellationException
2223
import org.onebusaway.android.location.SearchCenter
2324
import org.onebusaway.android.models.ObaRoute
2425
import org.onebusaway.android.util.routeDisplayNames
@@ -73,7 +74,12 @@ class DefaultRouteSearchRepository(
7374
}
7475
}
7576
routes.map(::toResult)
76-
}.onFailure { Log.e(TAG, "route search failed", it) }
77+
}.onFailure {
78+
// transformLatest cancels the in-flight search on each keystroke; let that cancellation
79+
// propagate instead of reporting it to the UI as a search Error.
80+
if (it is CancellationException) throw it
81+
Log.e(TAG, "route search failed", it)
82+
}
7783

7884
private fun toResult(route: ObaRoute): RouteSearchResult {
7985
val names = routeDisplayNames(route.shortName, route.longName, route.description)

onebusaway-android/src/main/java/org/onebusaway/android/ui/search/StopSearchRepository.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import org.onebusaway.android.api.data.LocationSearchDataSource
2020
import android.content.Context
2121
import android.util.Log
2222
import java.io.IOException
23+
import kotlinx.coroutines.CancellationException
2324
import kotlinx.coroutines.Dispatchers
2425
import kotlinx.coroutines.withContext
2526
import org.onebusaway.android.app.di.DatabaseEntryPoint
@@ -80,7 +81,10 @@ class DefaultStopSearchRepository(
8081
db.importGate().awaitReady()
8182
val userInfo = db.stopDao().userInfoMap().toStopUserInfoMap()
8283
stops.map { it.toStopSearchResult(userInfo[it.id]) }
83-
}.onFailure { Log.e(TAG, "stop search failed", it) }
84+
}.onFailure {
85+
if (it is CancellationException) throw it
86+
Log.e(TAG, "stop search failed", it)
87+
}
8488
}
8589

8690
private companion object {

onebusaway-android/src/main/java/org/onebusaway/android/ui/searchresults/SearchResultsRepository.kt

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import org.onebusaway.android.api.data.RoutesNearResult
2121
import javax.inject.Inject
2222
import android.location.Location
2323
import java.io.IOException
24+
import kotlinx.coroutines.CancellationException
2425
import kotlinx.coroutines.async
2526
import kotlinx.coroutines.coroutineScope
2627
import org.onebusaway.android.database.oba.ImportGate
@@ -57,8 +58,14 @@ class DefaultSearchResultsRepository @Inject constructor(
5758
?: return@coroutineScope Result.failure(IOException("No search location available"))
5859

5960
// Each search resolves a non-OK code / transport failure to Result.failure (requireData).
60-
val routes = async { runCatching { searchRoutes(query, center) } }
61-
val stops = async { runCatching { searchStops(query, center) } }
61+
// Keep a cancelled search from being coalesced into Result.failure here; rethrowing lets it
62+
// propagate through await() and cancel the sibling search too.
63+
val routes = async {
64+
runCatching { searchRoutes(query, center) }.onFailure { if (it is CancellationException) throw it }
65+
}
66+
val stops = async {
67+
runCatching { searchStops(query, center) }.onFailure { if (it is CancellationException) throw it }
68+
}
6269
val routeResult = routes.await()
6370
val stopResult = stops.await()
6471

@@ -73,7 +80,11 @@ class DefaultSearchResultsRepository @Inject constructor(
7380
// The favourite/custom-name enrichment is best-effort: a DB hiccup here must not fail a search
7481
// that already has route/stop results, so treat it as a soft miss (empty map) like the lookups.
7582
importGate.awaitReady()
76-
val userInfo = runCatching { stopDao.userInfoMap().toStopUserInfoMap() }.getOrDefault(emptyMap())
83+
val userInfo = runCatching { stopDao.userInfoMap().toStopUserInfoMap() }
84+
.getOrElse {
85+
if (it is CancellationException) throw it
86+
emptyMap()
87+
}
7788
val items = buildList {
7889
routeResult.getOrNull()?.let { result ->
7990
result.routes.forEach { add(toRoute(it, result.agencyNames)) }

onebusaway-android/src/main/java/org/onebusaway/android/ui/tripinfo/TripInfoRepository.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import android.content.Context
1919
import android.text.format.DateUtils
2020
import dagger.hilt.android.qualifiers.ApplicationContext
2121
import javax.inject.Inject
22+
import kotlinx.coroutines.CancellationException
2223
import kotlinx.coroutines.Dispatchers
2324
import kotlinx.coroutines.withContext
2425
import org.onebusaway.android.R
@@ -270,7 +271,11 @@ class DefaultTripInfoRepository @Inject constructor(
270271
secondsBefore = reminderSeconds,
271272
vehicleId = data.vehicleId,
272273
).url
273-
}.getOrNull()
274+
}.getOrElse {
275+
// Don't let a cancelled save be reported to the UI as a save failure; propagate cancellation.
276+
if (it is CancellationException) throw it
277+
null
278+
}
274279
}
275280

276281
companion object {

0 commit comments

Comments
 (0)