Test DefaultArrivalsRepository's stale-fallback/CAS concurrency against the real class (#1909)#1932
Conversation
…st the real class (#1909) The single-write LastGood holder and the fresh-wins-over-stale compareAndSet were subtle race fixes (#1865) whose reasoning lived only in comments; no test constructed the real class, so a future simplification could re-break them invisibly. The class couldn't be built on the JVM because of three incidental Android tendrils, each now a small, independently-justified seam (behavior unchanged): - ArrivalsDisplay: the Android presentation edge (convertArrivals + stop error strings) behind an interface; DefaultArrivalsDisplay holds the Context, which leaves DefaultArrivalsRepository's constructor entirely — completing the quarantine its docstring already claimed. ArrivalInfo already tolerates a null context, so a test fake builds real display models with real ETA math. - ElapsedClock (time/): the injectable ElapsedTime.now() source — the monotonic counterpart of the existing TimeProvider, and the class-boundary form of the "pass the now in" rule — so the stale projection is testable with an exact elapsed delta. - RouteFavorites: the narrow starring surface (interface segregation) of RouteFavoritesRepository, whose full dependency set (Context/analytics/app scope) isn't JVM-constructible. DefaultArrivalsRepositoryTest exercises the real class with hand-rolled fakes (house style; real StopArrivals wire fixtures): the fresh-load publish and its lastLoaded()/focusedTrips derivation, the widen loop, once-per-session stop recording, the no-prior-data failure message, the stale projection advancing ETAs by exactly the elapsed device time (#1612), the uncontended CAS refreshing the derived map snapshot, and the interleaved race — the stale path parked between its lastGood read and its CAS (a gated fake StopDao.userInfo) while a fresh load publishes — proving a stale re-projection can never roll back a concurrently-published fresh snapshot. Verified on-device: arrivals for a Puget Sound stop render fully through the new wiring (rows, ETA pills, window footnote). Fixes #1909 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
📝 WalkthroughWalkthroughAdds injectable seams for monotonic time, arrivals display conversion, and route favorites. Refactors ChangesArrivals repository seams and validation
Estimated code review effort: 3 (Moderate) | ~25 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 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.
Actionable comments posted: 1
🤖 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.
Inline comments:
In
`@onebusaway-android/src/test/java/org/onebusaway/android/ui/arrivals/DefaultArrivalsRepositoryTest.kt`:
- Around line 395-399: Update ArrivalsViewModel.refresh() to guard the
loaded.value = data assignment with latest-wins generation or cancellation
validation, so results from older overlapping refreshes are discarded before
updating UI state. Preserve the existing lastLoaded() behavior and allow only
the most recent refresh completion to publish loaded data.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 1e2591b2-9c7c-44ab-9848-79ea515a2c9f
📒 Files selected for processing (7)
onebusaway-android/src/main/java/org/onebusaway/android/app/di/AppModule.ktonebusaway-android/src/main/java/org/onebusaway/android/app/di/RepositoryModule.ktonebusaway-android/src/main/java/org/onebusaway/android/database/oba/RouteFavoritesRepository.ktonebusaway-android/src/main/java/org/onebusaway/android/time/ElapsedClock.ktonebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalsDisplay.ktonebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalsRepository.ktonebusaway-android/src/test/java/org/onebusaway/android/ui/arrivals/DefaultArrivalsRepositoryTest.kt
…freshes (#1933) (#1934) * Guard ArrivalsViewModel.refresh() against out-of-order overlapping refreshes (#1933) refresh() applied every completed result unconditionally. The 60s poll loop awaits its suspend refresh() sequentially so it never overlaps itself, but manualRefresh() and loadMore() each viewModelScope.launch { refresh() } — so a user refresh (toolbar button, or the alert-dialog dismiss in ArrivalActionHandlers) can run concurrently with an in-flight poll and finish out of order. The older-started completion's `loaded.value = data` would then clobber the fresher result, surfacing a spurious isStale flag or older arrivals until the next poll corrected it (≤60s). The map snapshot lastLoaded() was already protected by the repository's compareAndSet (#1932); this closes the same gap one layer up, in the ViewModel's `loaded` StateFlow. Fix: a monotonic refreshGeneration bumped at the start of each refresh; a completion applies its result only while it's still the latest, else it drops out (no state write, no map-snapshot emit). All reads/writes are confined to the ViewModel dispatcher — the only suspension in refresh() is the fetch — so a plain Int needs no synchronization. Test drives two overlapping refreshes (per-call gates on the fake repository) completing out of order and asserts the older-started stale completion cannot overwrite the fresher one; verified red without the guard. Fixes #1933 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Update lastResponseTime only for the winning refresh completion Move the lastResponseTime stamp after the latest-wins guard so a superseded (later-completing) refresh no longer pushes the poll timer past the fresher completion's timestamp — the poll cadence now measures its 60s interval from the shown data's arrival. Addresses review feedback on #1934. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Fixes #1909 (from the 2026-07-16 debt review, finding 9).
Problem
The single-write
LastGoodholder and the fresh-wins-over-stalecompareAndSetinDefaultArrivalsRepositorywere subtle race fixes (#1865) whose reasoning existed only in comments.ArrivalsViewModelTestuses a fake repository; nothing constructed the real class, so a future simplification could re-break the concurrency invisibly.Why the class wasn't testable, and the fix
The real class couldn't be built on the JVM because of three incidental Android tendrils. Each is now a small, independently-justified seam — behavior unchanged, verified by the full unit suite on both flavors plus an on-device smoke test:
ArrivalsDisplay— the Android presentation edge (convertArrivals+ stop error strings) behind an interface.DefaultArrivalsDisplayholds theContext, which leavesDefaultArrivalsRepository's constructor entirely — completing the quarantine its docstring already claimed. Key discovery:ArrivalInfoalready tolerates a null context (the "Activity destroyed" guard), so the test fake builds real display models with real ETA math.ElapsedClock(time/) — the injectableElapsedTime.now()source, the monotonic counterpart of the existingTimeProviderand the class-boundary form of the "pass the now in" rule — so the stale projection is testable with an exact elapsed delta.RouteFavorites— the narrow starring surface (interface segregation) ofRouteFavoritesRepository, whose full dependency set (Context/analytics/app scope) isn't JVM-constructible. ItsgetArrivals-path use is nil; the@Singletonimpl is@Binds-bound.The tests (
DefaultArrivalsRepositoryTest, 7, all against the real class)Hand-rolled fakes (house style) over real
StopArrivalswire fixtures:lastLoaded()consistent with the displayed trips (realfocusedTripsderivation)windowEndunmovedfocusedTrips)lastGoodread and its CAS (a gated fakeStopDao.userInfo— the sameCompletableDeferredgate techniqueArrivalsViewModelTestuses), a fresh load publishes, and the released CAS loses — a stale re-projection can never roll back a concurrently-published fresh snapshot.Verification
testObaGoogleDebugUnitTest+testObaMaplibreDebugUnitTestgreen with-PwarningsAsErrors=true; Hilt graph compiles.1_75403): arrivals render fully through the new wiring — header, route rows with real labels, ETA pills, "Showing trips until…" footnote, Load more.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Tests