Skip to content

Commit 7fa89d8

Browse files
bmanderclaude
andauthored
Guard ArrivalsViewModel.refresh() against out-of-order overlapping refreshes (#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>
1 parent c4e00e0 commit 7fa89d8

2 files changed

Lines changed: 60 additions & 5 deletions

File tree

onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalsViewModel.kt

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,18 +100,34 @@ class ArrivalsViewModel @AssistedInject constructor(
100100
var lastResponseTime: WallTime = WallTime(0L)
101101
private set
102102

103+
/**
104+
* Bumped at the start of every [refresh]; a refresh applies its result only while it's still the
105+
* latest. The poll loop awaits its own `refresh()` sequentially, but [manualRefresh]/[loadMore]
106+
* each launch one, so a user refresh can run concurrently with an in-flight poll and finish out of
107+
* order — without this guard the older-started one's `loaded.value = data` would clobber the fresher
108+
* result (a spurious stale flag / older arrivals). Confined to the ViewModel dispatcher (the only
109+
* suspension in [refresh] is the fetch), so a plain `Int` needs no synchronization. See #1933.
110+
*/
111+
private var refreshGeneration = 0
112+
103113
/**
104114
* Loads the arrivals once. Suspends until done so the screen's polling loop can measure the
105115
* 60s interval from completion. A failed refresh keeps any existing content (the repository
106116
* already returns the last good data as stale); it only surfaces [ArrivalsUiState.Error]
107117
* when there is nothing to show.
108118
*
109-
* Returns true when a *fresh* network response landed — a stale fallback (the repository
110-
* returns the last good data flagged `isStale` on failure-with-content) or a hard failure
111-
* returns false. Consumed by [loadMore]; the poll loop ignores it.
119+
* Returns true when a *fresh* network response landed and was applied — a stale fallback (the
120+
* repository returns the last good data flagged `isStale` on failure-with-content), a hard failure,
121+
* or a completion superseded by a newer refresh (#1933) returns false. Consumed by [loadMore]; the
122+
* poll loop ignores it.
112123
*/
113124
suspend fun refresh(): Boolean {
125+
val generation = ++refreshGeneration
114126
val result = repository.getArrivals(stopId, minutesAfter)
127+
// A newer refresh started while this one was in flight — drop this (now stale) completion so it
128+
// can't overwrite the fresher state, emit an out-of-date map snapshot, or push the poll timer
129+
// (measured against the *shown* data) past the fresher completion. See #1933.
130+
if (generation != refreshGeneration) return false
115131
lastResponseTime = WallTime.now()
116132
return result.fold(
117133
onSuccess = { data ->

onebusaway-android/src/test/java/org/onebusaway/android/ui/arrivals/ArrivalsViewModelTest.kt

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ private class FakeArrivalsRepository(
5151
* (e.g. to fire a superseding load-more before the first finishes). */
5252
var gate: CompletableDeferred<Unit>? = null
5353

54+
/** Per-call gate/result overrides indexed by call order, for driving *overlapping* loads that
55+
* complete out of order (issue #1933). When a slot is present the Nth [getArrivals] awaits that
56+
* gate and returns that result instead of [gate]/[result]. */
57+
val callGates = mutableListOf<CompletableDeferred<Unit>>()
58+
val callResults = mutableListOf<Result<ArrivalsData>>()
59+
5460
var lastFavoriteSet: Pair<String, Boolean>? = null
5561

5662
var lastFavoriteRoute: FavoriteRouteCall? = null
@@ -72,9 +78,10 @@ private class FakeArrivalsRepository(
7278
stopId: String,
7379
minutesAfter: Int
7480
): Result<ArrivalsData> {
81+
val call = requestedMinutesAfter.size
7582
requestedMinutesAfter.add(minutesAfter)
76-
gate?.await()
77-
return result
83+
(callGates.getOrNull(call) ?: gate)?.await()
84+
return callResults.getOrNull(call) ?: result
7885
}
7986

8087
override suspend fun setStopFavorite(stopId: String, favorite: Boolean) {
@@ -254,6 +261,38 @@ class ArrivalsViewModelTest {
254261
assertEquals(listOf(65, 125), repository.requestedMinutesAfter)
255262
}
256263

264+
// --- Overlapping out-of-order refreshes (latest-wins guard, #1933) -------------------------
265+
266+
@Test
267+
fun `an out-of-order overlapping refresh cannot clobber the fresher result with a stale one`() = runTest {
268+
// The poll refresh (call #0) starts first but lands LAST as a stale fallback; a user refresh
269+
// (call #1) starts later and lands FIRST with fresh data. The stale, older-started completion
270+
// must not overwrite the fresh one it superseded. Regression for #1933.
271+
val repository = FakeArrivalsRepository(Result.success(data()))
272+
repository.callGates += CompletableDeferred() // call #0 (poll)
273+
repository.callGates += CompletableDeferred() // call #1 (user refresh)
274+
repository.callResults += Result.success(data(minutesAfter = 65, isStale = true)) // #0 stale fallback
275+
repository.callResults += Result.success(data(minutesAfter = 125, isStale = false)) // #1 fresh
276+
val viewModel = ArrivalsViewModel("1_100", repository)
277+
278+
viewModel.manualRefresh() // poll-like refresh, parks on gate #0
279+
viewModel.manualRefresh() // superseding refresh, parks on gate #1
280+
281+
// The later-started refresh lands first with fresh data.
282+
repository.callGates[1].complete(Unit)
283+
advanceUntilIdle()
284+
val fresh = viewModel.state.value as ArrivalsUiState.Content
285+
assertFalse(fresh.isStale)
286+
assertEquals(ServerTime(125 * 60_000L), fresh.windowEnd)
287+
288+
// The earlier-started refresh lands last as a stale fallback — it must be dropped, not applied.
289+
repository.callGates[0].complete(Unit)
290+
advanceUntilIdle()
291+
val afterStale = viewModel.state.value as ArrivalsUiState.Content
292+
assertFalse(afterStale.isStale)
293+
assertEquals(ServerTime(125 * 60_000L), afterStale.windowEnd)
294+
}
295+
257296
@Test
258297
fun `toggle favorite optimistically updates the header and persists`() = runTest {
259298
val repository = FakeArrivalsRepository(Result.success(data(favorite = false)))

0 commit comments

Comments
 (0)