Repository navigation
fix: deckpicker viewmodel errors - #22504
criticalAY wants to merge 25 commits into
Conversation
eec65cf to
d543cc9
Compare
|
Snapshot diff report: Previous regressions were resolved by the latest commit. |
d543cc9 to
e3885b9
Compare
e3885b9 to
0fb8434
Compare
0fb8434 to
d8d16bd
Compare
|
Legit failure, fixed it |
mikehardy
left a comment
There was a problem hiding this comment.
Assisted-by: cursor-grok-4.6-medium
The stopped-lifecycle and database-corruption tests from the last request are on this tip. ViewModel failures now go through MessageQueue into showException instead of crashing.
Rebased d8d16bd onto main (7e29d24) cleanly and did not push. Local tip is 695da54f. Lint (lintAll, ktlintCheck, lint-rules:test) passed in 55s. Unit (jacocoUnitTestReport) failed once on CardContentProviderStartupTest and a flaky ReviewerNoParamTest; both passed alone, and neither class is in this diff. DeckPickerErrorTest was 9/9. Emulator tests on emulator-5554 (API 36, playRelease) passed. The XML noise was the usual phone-sized failures plus a tablet assumption in DeckPickerImportTest, which this diff does not touch. ReviewRemindersTest was 9/9.
showException is the same dialog path runCatching already used, including corruption recovery. Delete, empty cards, filtered rebuild and empty, select, and deck-list reload catch into a queue that DeckPicker drains after CREATED and withStarted. Rebuild refresh stays on the ViewModel flow. The Activity no longer also calls updateDeckList and tryShowStudyOptionsPanel after join.
some acceptable findings, no action required, just leaving for posterity: Approach is "check commit messages"; launchCatchingIO still uses the string onError SharedFlow.
d8d16bd to
ac26a8c
Compare
|
@criticalAY Tests are failing |
ac26a8c to
a455fa0
Compare
A refresh that couldn't read the current deck crashed the app while redrawing the deck list. The list now keeps showing the last decks it loaded, and the error is shown like the others, so database corruption, for example while filtering, opens the recovery dialog. A refresh reads the current deck first, so it still reports one error. Assisted-by: Claude Opus 5.5
… queue Errors from actions such as renaming, opening deck options or unburying were plain messages: they were lost while the screen was stopped and corruption didn't open the recovery dialog. They now use the same queue as the other Deck Picker errors. Assisted-by: Claude Opus 5.5
The deletion test clicked the "Other" deck as soon as the deck tree had loaded, but the rows are drawn a moment later, so on a slow runner the click could come before the row existed. It now waits until both decks are shown. Assisted-by: Claude Opus 5.5
The next queued error, or a message such as a failed background backup, opened on top of the database recovery dialog and hid it. They now wait until no recovery dialog is open and the screen is started, and check again right before they show, in case another recovery dialog opened while they waited. They also let a closing dialog be fully removed first, so a queued corruption error opens recovery instead of becoming a notification. Assisted-by: Claude Opus 5.5
Deleting a deck, and emptying or rebuilding a filtered deck, take more than one backend call. If a later one failed, for example selecting the deck that replaces a deleted one, the error was shown but the deck list and the tablet study options kept showing the old state. The same happened when selecting a newly created deck failed, so the new deck was missing from the list. The Deck Picker now reloads after these failures. It skips the reload when the database is corrupt, as the recovery dialog handles that and a reload would only queue a second recovery prompt. Assisted-by: Claude Opus 5.5
On tablets, Delete deck in the toolbar and Shift+Delete delete the selected deck without asking, because the side panel shows it. If a failure left the panel showing another deck, for example one whose deletion had already gone through, or the panel showed something else such as review reminders, they deleted a deck that wasn't shown. They now go by the deck the panel last drew, since its data can change before it redraws, and reload the deck list when that isn't the selected deck or the panel shows no deck. Assisted-by: Claude Opus 5.5
68f6a52 to
cbed887
Compare
The side panel recorded the deck it had drawn by asking its ViewModel, which a refresh can move to another deck before the panel draws it. If the panel redrew its older state in between, for example when it started again during the refresh, Shift+Delete deleted the new deck while the panel still showed the old one. The panel's state now carries its deck, and the panel records that deck when it draws the state. Assisted-by: Claude Opus 5.5
A refresh reads the current deck first and moves the deck list's focus to it at the end, so a deck selected while the refresh ran was replaced by the one the refresh had read, and the list jumped back to it. The refresh now leaves the focus alone if it changed in the meantime. Assisted-by: Claude Opus 5.5
A refresh checked that nobody had changed the focused deck and then set it, but a deck selected between the check and the update was still replaced by the refresh's older deck. The check and the update now run together on the collection queue, and selecting or deleting a deck updates the focus there too, so they can't interleave. Assisted-by: Claude Opus 5.5
The deck list only shows a deck as collapsed or expanded once the collection has saved it, so a second tap before that save finished read the old state and saved the same value again. Collapsing a deck while a refresh was reading the decks could also be undone when that refresh showed its older copy of the list. Collapsing and expanding now happen one at a time, after any refresh that is already running. Assisted-by: Claude Opus 5.5
david-allison
left a comment
There was a problem hiding this comment.
GPT-6.1
Subject: [PATCH] PR 22504: test replacement refresh expansion race
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerReplacementRefreshReviewTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerReplacementRefreshReviewTest.kt
new file mode 100644
index 0000000000..3acce2a009
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerReplacementRefreshReviewTest.kt
@@ -0,0 +1,112 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki
+
+import androidx.lifecycle.viewModelScope
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import anki.decks.DeckTreeNode
+import com.ichi2.anki.deckpicker.DeckPickerViewModel
+import com.ichi2.anki.libanki.sched.DeckNode
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.runBlocking
+import net.ankiweb.rsdroid.Backend
+import net.ankiweb.rsdroid.BackendFactory
+import org.junit.After
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.concurrent.thread
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+@OptIn(ExperimentalForInheritanceCoroutinesApi::class)
+@RunWith(AndroidJUnit4::class)
+class DeckPickerReplacementRefreshReviewTest : RobolectricTest() {
+ @After
+ fun resetBackend() = BackendFactory.setOverride(null)
+
+ @Test
+ fun `refresh replacing the one awaited by a toggle must not publish stale expansion`() {
+ val readingFirst = CountDownLatch(1)
+ val releaseFirst = CountDownLatch(1)
+ val pauseFirst = AtomicBoolean(false)
+ runBlocking { CollectionManager.discardBackend() }
+ BackendFactory.setOverride {
+ object : Backend() {
+ override fun deckTree(now: Long): DeckTreeNode {
+ if (pauseFirst.compareAndSet(true, false)) {
+ readingFirst.countDown()
+ check(releaseFirst.await(10, TimeUnit.SECONDS))
+ }
+ return super.deckTree(now)
+ }
+ }
+ }
+ val parent = addDeck("Parent")
+ addDeck("Parent::Child")
+ val model = DeckPickerViewModel()
+ val initial = model.reloadDeckCounts()
+ advanceRobolectricLooperUntil { initial.isCompleted }
+ val initiallyCollapsed = model.dueTree!!.find(parent)!!.collapsed
+ val treeField = model.javaClass.getDeclaredField("flowOfDeckDueTree").apply { isAccessible = true }
+ @Suppress("UNCHECKED_CAST")
+ val original = treeField.get(model) as MutableStateFlow<DeckNode?>
+ val publishingReplacement = CountDownLatch(1)
+ val releaseReplacement = CountDownLatch(1)
+ val gatePublication = AtomicBoolean(true)
+ treeField.set(model, object : MutableStateFlow<DeckNode?> by original {
+ override var value: DeckNode?
+ get() = original.value
+ set(value) {
+ if (gatePublication.compareAndSet(true, false)) {
+ publishingReplacement.countDown()
+ check(releaseReplacement.await(10, TimeUnit.SECONDS))
+ }
+ original.value = value
+ }
+ })
+ val previousQueue = CollectionManager.setTestDispatcher(Dispatchers.IO.limitedParallelism(1), useReentrantLock = false)
+ try {
+ addDeck("New deck that makes the replacement snapshot different")
+ pauseFirst.set(true)
+ val first = model.reloadDeckCounts()
+ assertTrue(readingFirst.await(5, TimeUnit.SECONDS))
+ // This action waits for the first refresh, which a subsequent refresh cancels.
+ val toggle = model.toggleDeckExpand(parent)
+ val replacement = model.reloadDeckCounts()
+ releaseFirst.countDown()
+ // A shared lock may let the toggle finish before the replacement reads its tree.
+ // Give that correct implementation a chance to drain Main as well.
+ if (!publishingReplacement.await(2, TimeUnit.SECONDS)) {
+ advanceRobolectricLooperUntil { publishingReplacement.count == 0L }
+ }
+ val toggleCompleted = CountDownLatch(1)
+ toggle.invokeOnCompletion { toggleCompleted.countDown() }
+ val releaser = thread(name = "release-replacement-refresh") {
+ // A correct implementation can serialize the toggle behind this refresh.
+ // Permit that ordering while exposing the stale publication in the current code.
+ toggleCompleted.await(2, TimeUnit.SECONDS)
+ releaseReplacement.countDown()
+ }
+ advanceRobolectricLooperUntil { first.isCompleted && toggle.isCompleted && replacement.isCompleted }
+ releaser.join()
+ assertEquals(!initiallyCollapsed, col.decks.get(parent)!!.collapsed)
+ assertEquals(
+ col.decks.get(parent)!!.collapsed,
+ model.dueTree!!.find(parent)!!.collapsed,
+ "The replacement refresh must not undo the saved expansion in the displayed tree",
+ )
+ } finally {
+ releaseFirst.countDown()
+ releaseReplacement.countDown()
+ treeField.set(model, original)
+ model.viewModelScope.cancel()
+ CollectionManager.setTestDispatcher(previousQueue)
+ }
+ }
+}
Waiting for a running refresh before collapsing a deck wasn't enough: a second refresh could replace the one being waited for and still show a tree it read before the collapse was saved. A refresh now shows its tree, and whether the collection has cards, in the same collection call that reads them, and collapsing reads, saves and updates the deck in one collection call, so a tree read before a change can't be shown after it. Assisted-by: Claude Opus 5.5
A refresh moved the focus to the deck it read when it started, unless the focus had been set in the meantime. A deck selected another way while it ran, for example by rebuilding a filtered deck, was then replaced by the older one. The refresh now reads the current deck again when it sets the focus. It still reads it before showing the tree too, so a deck it can't read stops the refresh before the list changes. Assisted-by: Claude Opus 5.5
Emptying a filtered deck selected it, then emptied whichever deck was selected in a separate collection call, so a deck tapped in between was emptied instead. It now empties the deck that was chosen. Assisted-by: Claude Opus 5.5
A failure that kept happening, such as a refresh that couldn't read the decks, was queued again by every refresh and by the screen restarting, so the same error came back after each dismissal and sent another crash report. An error that matches one still waiting is now dropped. Assisted-by: Claude Opus 5.5
Tapping a deck set the focus once its collection call had returned, so a deck selected another way in the meantime, for example by long pressing it, lost the focus to the tapped one. The tap now sets it in the same collection call that selects the deck, like the other selections, and the studied today line is written the same way. Assisted-by: Claude Opus 5.5
Deleting the same deck twice in quick succession, for example by pressing Shift+Delete twice, deleted it and then showed an error for the second request. The second request now finds the deck gone and does nothing. Assisted-by: Claude Opus 5.5
A corruption error that came in while the recovery dialog was open waited for it to close and then opened it again, even after the collection had been repaired. Such an error is now dropped when the recovery dialog closes. Errors that waited for other database dialogs are still shown. Assisted-by: Claude Opus 5.5
On tablets the side panel showed its own refresh errors straight away, so a failure that it and the deck list both hit was shown up to three times on each return to the app, without waiting for the screen to start or for database recovery. The panel now passes them to the deck picker's queue. Assisted-by: Claude Opus 5.5
Only a successful refresh hid the loading spinner, so after a failed first load it kept spinning over an empty screen. A failed refresh now hides it too. Assisted-by: Claude Opus 5.5
Note
Assisted-by: Opus 5.5
Purpose / Description
Deck Picker crashed if the backend failed while deleting a deck, deleting empty cards, rebuilding or emptying a filtered deck, selecting a new deck or refreshing the list. The ViewModel now catches these errors and shows them, as David asked on #21831.
Fixes
Approach
check commit messages
How Has This Been Tested?
added tests, the error ones crashed before the fix
Learning (optional, can help others)
NA
Checklist
Please, go through these checks before submitting the PR.