fix(deck-picker): do not block the main thread on startup during sync - #21334
criticalAY wants to merge 1 commit into
Conversation
|
Could you provide reproduction instructions (probably a The startup path has historically been nasty. This initially looks good (and the ViewModel makes things easier), but I'd like to run it through a debugger to confirm. |
Subject: [PATCH] repro: sync anr
---
Index: AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt b/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt
--- a/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt (revision 25da95364f62624be98404df64afae4d699f134c)
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt (date 1783413600156)
@@ -159,6 +159,8 @@
manualCancelButton = R.string.dialog_cancel,
) {
withCol {
+ Timber.w("#21305 repro: holding the collection queue for 60s")
+ Thread.sleep(60_000)
syncCollection(auth2, syncMedia = false) // media is synced by SyncMediaWorker
}
}
Index: AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt
--- a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt (revision 25da95364f62624be98404df64afae4d699f134c)
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt (date 1783413571943)
@@ -522,17 +522,11 @@
}
Timber.d("handleStartup: Continuing after permission granted")
- viewModelScope.launch {
- // opening the collection waits on the collection queue, which a sync stuck on an
- // unresponsive server can hold for a long time. Waiting on the main thread here
- // froze the DeckPicker when it was recreated
- val failure =
- withContext(Dispatchers.IO) {
- InitialActivity.getStartupFailureType(environment::initializeAnkiDroidFolder)
- }
+ run {
+ val failure = InitialActivity.getStartupFailureType(environment::initializeAnkiDroidFolder)
if (failure != null) {
flowOfStartupResponse.value = StartupResponse.FatalError(failure)
- return@launch
+ return
}
// successful startup
|
25da953 to
f228070
Compare
david-allison
left a comment
There was a problem hiding this comment.
One suspected blocker, a few nits on the test
| InitialActivity.getStartupFailureType(environment.preferences, environment::initializeAnkiDroidFolder) | ||
| } | ||
| if (failure != null) { | ||
| flowOfStartupResponse.value = StartupResponse.FatalError(failure) |
There was a problem hiding this comment.
I believe this no longer blanks the options menu.
We don't refresh the menu here, or in DeckPicker::onStartupResponse
Could you add a test to confirm?
ccba2e4 to
1281387
Compare
|
|
Ok let me resolve the conflicts/issues and lets get it in |
1281387 to
a8ac5f3
Compare
david-allison
left a comment
There was a problem hiding this comment.
GPT-generated regression test, needs fixing
Index: AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerStartupTest.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerStartupTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerStartupTest.kt
new file mode 100644
--- /dev/null (date 1790437094418)
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerStartupTest.kt (date 1790437094418)
@@ -0,0 +1,98 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki
+
+import android.content.SharedPreferences
+import androidx.core.content.edit
+import androidx.lifecycle.ViewModelProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.ichi2.anki.deckpicker.DeckPickerViewModel
+import com.ichi2.anki.deckpicker.DeckPickerViewModel.AnkiDroidEnvironment
+import com.ichi2.anki.testutils.SingleViewModelFactory
+import com.ichi2.testutils.BackupManagerTestUtilities
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.doAnswer
+import org.mockito.kotlin.doCallRealMethod
+import org.mockito.kotlin.spy
+import org.mockito.kotlin.whenever
+import org.robolectric.Robolectric
+import org.robolectric.annotation.Config
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+import kotlin.time.Duration.Companion.seconds
+
+@RunWith(AndroidJUnit4::class)
+@Config(qualifiers = "normal")
+class DeckPickerStartupTest : RobolectricTest() {
+ override fun getCollectionStorageMode() = CollectionStorageMode.ON_DISK
+
+ @Test
+ fun `decks load when startup opens the collection after the initial refresh`() =
+ runTest {
+ setIntroductionSlidesShown(true)
+ InitialActivity.setUpgradedToLatestVersion(getPreferences())
+ getPreferences().edit { putBoolean("backupPromptDisabled", true) }
+ BackupManagerTestUtilities.setupSpaceForBackup(targetContext)
+ addBasicNote("cold start", "should appear")
+ CollectionManager.closeCollectionBlocking()
+
+ val releaseStartup = CountDownLatch(1)
+ val viewModel = spy(DeckPickerViewModel())
+ // Hold the real startup check before it opens the collection, without occupying
+ // the collection queue: onResume must be able to observe the closed collection.
+ doAnswer { invocation ->
+ val environment = invocation.getArgument<AnkiDroidEnvironment>(0)
+ doCallRealMethod().whenever(viewModel).handleStartup(any())
+ viewModel.handleStartup(
+ object : AnkiDroidEnvironment by environment {
+ override val preferences: SharedPreferences
+ get() {
+ check(releaseStartup.await(10.seconds.inWholeMilliseconds, TimeUnit.MILLISECONDS)) {
+ "The background startup check was not released"
+ }
+ return environment.preferences
+ }
+ },
+ )
+ }.whenever(viewModel).handleStartup(any())
+
+ val controller = Robolectric.buildActivity(DeckPicker::class.java)
+ saveControllerForCleanup(controller)
+ val activity = controller.get()
+ ViewModelProvider(activity, SingleViewModelFactory.create(viewModel))[DeckPickerViewModel::class.java]
+ try {
+ controller
+ .create()
+ .start()
+ .resume()
+ .visible()
+ // Complete a refresh with the collection still closed, as can happen onResume.
+ viewModel.updateDeckList().join()
+ assertFalse(CollectionManager.isOpenUnsafe(), "Startup must not have opened the collection yet")
+
+ releaseStartup.countDown()
+ viewModel.startupJob!!.join()
+ advanceRobolectricLooperUntil {
+ viewModel.flowOfStartupResponse.value == null
+ }
+ assertTrue(CollectionManager.isOpenUnsafe(), "Startup should have opened the collection")
+
+ advanceRobolectricLooperUntil(
+ timeout = 5.seconds,
+ lazyMessage = {
+ "Successful startup must display the decks even if the initial refresh ran before the collection opened"
+ },
+ ) {
+ activity.hasAtLeastOneDeckBeingDisplayed()
+ }
+ } finally {
+ releaseStartup.countDown()
+ viewModel.startupJob?.join()
+ BackupManagerTestUtilities.reset()
+ }
+ }
+}a8ac5f3 to
6044211
Compare
6044211 to
28410c2
Compare
DeckPicker recreation (for example a light/dark theme change) ran InitialActivity.getStartupFailureType on the main thread, which waits on the collection queue. A sync stuck on an unresponsive server holds that queue for the entire network call, so the recreated activity froze and ANRed after 5 seconds. Run the check on Dispatchers.IO and deliver the result through flowOfStartupResponse instead. The check can now finish after onResume, so the first deck list refresh may find the collection still closed and skip it, and the options menu may have read its state while the collection was closed. Once startup succeeds, refresh the deck list and rebuild the menu. Tests that start DeckPicker relied on the check finishing inside onCreate, so the shared start helper now waits for it. Assisted-by: Claude Opus 4.8 (some part of the PR)
28410c2 to
db946bd
Compare
|
Thanks for the test, the first deck refresh could run before the async startup opened the collection, so Success now refreshes the deck list and rebuilds the menu (the menu had the same gap with an empty collection). Umm also fixed theCI failure, a test racing the async startup, by making the shared start helper wait for DeckPicker's startup; the trade-off is a second deck load on each DeckPicker creation, which I can avoid if you'd prefer. |
|
Let's do it right, now the cost of doing it right is so much less First impressions matter |
Note
Assisted-by: Claude Opus 4.8 (some diagnosis and a few parts of code)
Purpose / Description
Fixes an ANR when the DeckPicker is recreated while a sync is stuck on an unresponsive server (for example AnkiWeb being down).
Fixes
Approach
See commit
How Has This Been Tested?
Unit test
Learning (optional, can help others)
NA
Checklist
Please, go through these checks before submitting the PR.