From 25da95364f62624be98404df64afae4d699f134c Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Thu, 2 Jul 2026 04:14:56 +0530 Subject: [PATCH] fix(deck-picker): do not block the main thread on startup during sync 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. Assisted-by: Claude Opus 4.8 (some part of the PR) --- .../anki/deckpicker/DeckPickerViewModel.kt | 24 +++++--- .../deckpicker/DeckPickerViewModelTest.kt | 58 +++++++++++++++++++ 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt index 182c2ac6b519..ad171606af5d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt @@ -522,17 +522,25 @@ class DeckPickerViewModel : } Timber.d("handleStartup: Continuing after permission granted") - val failure = InitialActivity.getStartupFailureType(environment::initializeAnkiDroidFolder) - if (failure != null) { - flowOfStartupResponse.value = StartupResponse.FatalError(failure) - return - } + 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) + } + if (failure != null) { + flowOfStartupResponse.value = StartupResponse.FatalError(failure) + return@launch + } - // successful startup + // successful startup - configureRenderingMode() + configureRenderingMode() - flowOfStartupResponse.value = StartupResponse.Success + flowOfStartupResponse.value = StartupResponse.Success + } } interface AnkiDroidEnvironment { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/DeckPickerViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/DeckPickerViewModelTest.kt index a32f9c861bde..387ee3cdae7d 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/DeckPickerViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/DeckPickerViewModelTest.kt @@ -23,18 +23,25 @@ import anki.card_rendering.EmptyCardsReport import anki.card_rendering.emptyCardsReport import app.cash.turbine.test import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.PermissionSet import com.ichi2.anki.RobolectricTest import com.ichi2.anki.libanki.Consts import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.libanki.Note import com.ichi2.anki.libanki.emptyCids import com.ichi2.testutils.ensureOpsExecuted +import kotlinx.coroutines.runBlocking import org.hamcrest.CoreMatchers.not import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.lessThan import org.junit.Test import org.junit.runner.RunWith import timber.log.Timber +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread +import kotlin.system.measureTimeMillis import kotlin.test.assertEquals /** Test of [DeckPickerViewModel] */ @@ -239,4 +246,55 @@ class DeckPickerViewModelTest : RobolectricTest() { } } } + + @Test + fun `handleStartup does not block while a hung sync holds the collection queue`() { + col + val queueHeld = CountDownLatch(1) + val releaseQueue = CountDownLatch(1) + // a sync against an unresponsive server runs `withCol { syncCollection(...) }`, + // holding the collection queue for the whole network call + val hungSync = + thread(name = "hung-sync") { + runBlocking { + withCol { + queueHeld.countDown() + releaseQueue.await() + } + } + } + try { + assertThat("sync is holding the collection queue", queueHeld.await(5, TimeUnit.SECONDS), equalTo(true)) + + // if handleStartup blocks on the queue, free it after a delay so the test fails with a message instead of hanging + thread(name = "watchdog") { + if (!releaseQueue.await(WATCHDOG_MS, TimeUnit.MILLISECONDS)) { + releaseQueue.countDown() + } + } + + val elapsedMillis = measureTimeMillis { viewModel.handleStartup(grantedPermissionsEnvironment) } + + assertThat( + "handleStartup waited ${elapsedMillis}ms for the collection queue. On a device this ANRs when DeckPicker is recreated while a sync is stuck on an unresponsive server", + elapsedMillis, + lessThan(WATCHDOG_MS), + ) + } finally { + releaseQueue.countDown() + hungSync.join() + } + } + + private val grantedPermissionsEnvironment = + object : DeckPickerViewModel.AnkiDroidEnvironment { + override fun hasRequiredPermissions() = true + + override val requiredPermissions: PermissionSet + get() = error("unused: permissions are granted") + + override fun initializeAnkiDroidFolder() = true + } } + +private const val WATCHDOG_MS = 2000L