Enforce cancellation-safe runCatching: runCatchingCancellable + SwallowedCancellation lint check#1931
Conversation
Follow-up to #1928 (the audit): make the "runCatching swallows CancellationException in coroutine code" bug class impossible to reintroduce, deterministically, in CI. Two parts: 1. `runCatchingCancellable` (org.onebusaway.android.util) — the sanctioned wrapper. Behaviour-identical to kotlin.runCatching but rethrows CancellationException, so a cancelled coroutine propagates instead of being captured into Result.failure. `inline`, mirroring runCatching, so `block` may call suspend functions and the guard is free. 2. `SwallowedCancellation` lint check (:lint-rules) — fails the build on a bare `runCatching` whose nearest enclosing named function is `suspend` (matching detekt's SuspendFunSwallowedCancellation; suspend detected via the Continuation param on the light method). Lambdas are transparent, so `async { runCatching { … } }` in a suspend function is caught; a runCatching in a coroutine-builder lambda inside a non-suspend function is out of scope (guard those by hand). With one sanctioned wrapper the rule is a simple symbol ban — no fragile "does this lambda suspend" analysis and no guard-detection. Migrated every flagged site onto the wrapper: the #1928 inline-guard sites (ObaApiProvider, SurveyDataSource, WeatherRepository, RouteSearchRepository, StopSearchRepository, SearchResultsRepository, RouteInfoRepository, TripInfoRepository, MyListRepository, BikeStationsRepository) plus four pure-but-in-suspend-fn sites the rule newly surfaced (GeocodeRepository, TripResultsRepository x2, TripPlanRepository.plan). Net -24 lines — the wrapper reads cleaner than the inline guards. BikeStations keeps its inner `catch (CancellationException) { throw e }` (the rule covers runCatching, not try/catch). Renamed TimeDomainIssueRegistry -> OneBusAwayIssueRegistry (it's no longer time-only) and updated the manifest attribute. Added detector unit tests, lint-rules/README, and a CLAUDE.md section. Verified: lint-rules tests green; app compiles clean under -PwarningsAsErrors; a full app lint run flagged exactly one SwallowedCancellation (a temporary probe, since removed) and zero real sites — confirming registry discovery, the rule firing, and complete migration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
📝 WalkthroughWalkthroughAdds ChangesCancellation-safe coroutine handling
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant SuspendRepository
participant runCatchingCancellable
participant Coroutine
SuspendRepository->>runCatchingCancellable: Execute coroutine operation
runCatchingCancellable->>Coroutine: Rethrow CancellationException
runCatchingCancellable-->>SuspendRepository: Return Result for other failures
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
lint-rules/src/main/java/org/onebusaway/lint/SwallowedCancellationDetector.kt (1)
52-52: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider covering
mapCatchingandrecoverCatchingin the future.While
mapCatchingandrecoverCatchingare typically used for synchronous mappings, they internally userunCatchingand execute inline blocks. If a developer invokes asuspendfunction inside them, they will also silently swallowCancellationException.Since there are no cancellable wrappers for these operators yet, this is purely a future-proofing thought: you might consider eventually expanding the detector's scope and creating corresponding wrappers (e.g.,
mapCatchingCancellable) to make the protection completely foolproof.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lint-rules/src/main/java/org/onebusaway/lint/SwallowedCancellationDetector.kt` at line 52, Keep the current SwallowedCancellationDetector scope unchanged for now; record mapCatching and recoverCatching as future coverage and wrapper candidates without modifying getApplicableMethodNames or adding wrappers in this change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In
`@lint-rules/src/main/java/org/onebusaway/lint/SwallowedCancellationDetector.kt`:
- Line 52: Keep the current SwallowedCancellationDetector scope unchanged for
now; record mapCatching and recoverCatching as future coverage and wrapper
candidates without modifying getApplicableMethodNames or adding wrappers in this
change.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: cdfdd427-3d1e-4f9e-8a7f-76bf22c8d064
📒 Files selected for processing (21)
CLAUDE.mdlint-rules/README.mdlint-rules/build.gradle.ktslint-rules/src/main/java/org/onebusaway/lint/OneBusAwayIssueRegistry.ktlint-rules/src/main/java/org/onebusaway/lint/SwallowedCancellationDetector.ktlint-rules/src/test/java/org/onebusaway/lint/SwallowedCancellationDetectorTest.ktonebusaway-android/src/main/java/org/onebusaway/android/api/data/StopsForRouteRepository.ktonebusaway-android/src/main/java/org/onebusaway/android/api/data/SurveyDataSource.ktonebusaway-android/src/main/java/org/onebusaway/android/api/net/ObaApiProvider.ktonebusaway-android/src/main/java/org/onebusaway/android/map/bike/BikeStationsRepository.ktonebusaway-android/src/main/java/org/onebusaway/android/ui/home/weather/WeatherRepository.ktonebusaway-android/src/main/java/org/onebusaway/android/ui/mylists/MyListRepository.ktonebusaway-android/src/main/java/org/onebusaway/android/ui/routeinfo/RouteInfoRepository.ktonebusaway-android/src/main/java/org/onebusaway/android/ui/search/RouteSearchRepository.ktonebusaway-android/src/main/java/org/onebusaway/android/ui/search/StopSearchRepository.ktonebusaway-android/src/main/java/org/onebusaway/android/ui/searchresults/SearchResultsRepository.ktonebusaway-android/src/main/java/org/onebusaway/android/ui/tripinfo/TripInfoRepository.ktonebusaway-android/src/main/java/org/onebusaway/android/ui/tripplan/GeocodeRepository.ktonebusaway-android/src/main/java/org/onebusaway/android/ui/tripplan/TripPlanRepository.ktonebusaway-android/src/main/java/org/onebusaway/android/ui/tripresults/TripResultsRepository.ktonebusaway-android/src/main/java/org/onebusaway/android/util/RunCatchingCancellable.kt
Makes the "
runCatchingswallowsCancellationExceptionin coroutine code" bug class — audited in #1928, first fixed in #1921 — impossible to reintroduce, deterministically, in CI. The "easy to get wrong" pattern becomes a build failure with a one-line fix.Two parts
1.
runCatchingCancellable— the sanctioned wrapperorg.onebusaway.android.util. Behaviour-identical tokotlin.runCatchingbut rethrowsCancellationException, so a cancelled coroutine propagates instead of being captured intoResult.failure.inline(mirrorsrunCatching), soblockmay call suspend functions and the guard costs nothing.2.
SwallowedCancellationlint check (:lint-rules)Fails the build on a bare
runCatchingwhose nearest enclosing named function issuspend— matching detekt'sSuspendFunSwallowedCancellation, but in the module's existing custom-lint infrastructure (no new tool, same strict-PwarningsAsErrorsgate, no baseline). Suspend is detected via theContinuationparameter on the light method.async { runCatching { … } }inside a suspend function is caught.runCatchingin a coroutine-builder lambda inside a non-suspend function is deliberately out of scope (boundary not visible without heuristics) — e.g.RouteMapController's launch-lambda sites keep their correct inline guards; the Compose sheet-animationrunCatchings inHomeScreensit in@Composables and are correctly ignored.runCatchingaround purely non-suspending work in a suspend function is still flagged; the wrapper is a strict superset, so it's never wrong.Migration
Every flagged site moved onto the wrapper — the api/repository sites from the #1928 audit plus four pure-but-in-
suspend-fn sites the rule newly surfaced (GeocodeRepository,TripResultsRepository×2,TripPlanRepository.plan), andStopsForRouteRepository.routeMap(from #1921, which merged meanwhile — the safeguard immediately caught it, exactly as intended). Net reduction in lines: the wrapper reads cleaner than the inline guards.BikeStationsRepositorykeeps its innercatch (CancellationException) { throw e }(the check coversrunCatching, nottry/catch— for those, add a leading cancellation catch by hand).Also renamed
TimeDomainIssueRegistry→OneBusAwayIssueRegistry(no longer time-only) + manifest attribute; added detector unit tests, alint-rules/READMEsection, and a CLAUDE.md section.Validation
:lint-rules:test— green (6 new detector tests: fires in a suspend fn, fires through a lambda, ignores the wrapper, ignores non-suspend fns and a same-named non-kotlinrunCatching).-PwarningsAsErrors; unit suite green.SwallowedCancellation— a temporary probe (since removed) — and zero real sites, confirming in one pass: registry discovery, the rule firing, and complete migration. (It also surfaced 2 pre-existingRestrictedApierrors onHctfrom Add adjacency-aware stop and route focus to the map #1865 and 2ModifierParameterwarnings in untouched files — a known local-vs-CI lint discrepancy, unrelated to this change.)🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
New Features
Documentation