diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt b/androidApp/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt index 587422d508..49c3b5ceb8 100644 --- a/androidApp/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt +++ b/androidApp/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt @@ -20,6 +20,7 @@ import android.app.Application import android.appwidget.AppWidgetProviderInfo import android.content.Context import android.os.Build +import androidx.annotation.VisibleForTesting import androidx.collection.intSetOf import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.work.Configuration @@ -30,6 +31,7 @@ import co.touchlab.kermit.Logger import co.touchlab.kermit.Severity import coil3.ImageLoader import coil3.SingletonImageLoader +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -68,7 +70,14 @@ open class MeshUtilApplication : Configuration.Provider, SingletonImageLoader.Factory { - protected val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + // SupervisorJob alone only isolates siblings: a failed child still reaches the global uncaught + // handler. These launches are best-effort background init, so report rather than escalate. + private val applicationScopeExceptionHandler = CoroutineExceptionHandler { context, throwable -> + Logger.e(throwable) { "Background application init failed in $context" } + } + + protected val applicationScope = + CoroutineScope(SupervisorJob() + Dispatchers.Default + applicationScopeExceptionHandler) /** Supplies Coil's process-wide loader without retaining an Activity in its singleton factory. */ override fun newImageLoader(context: Context): ImageLoader = get() @@ -144,10 +153,19 @@ open class MeshUtilApplication : } } - override fun onTerminate() { - // Robolectric never calls this, so unit tests booting this Application cannot rely on it. - // cancel() not cancelAndJoin(): joining under runBlocking on the main thread can deadlock. + /** + * Stops the background init launched by [onCreate]. Robolectric never calls [onTerminate], so a unit test that + * boots this Application must call this itself — otherwise those jobs outlive the test on real + * [Dispatchers.Default] threads and their failures surface against whichever test is running next. + */ + @VisibleForTesting + fun cancelBackgroundInit() { applicationScope.cancel() + } + + override fun onTerminate() { + // cancel() not cancelAndJoin(): joining under runBlocking on the main thread can deadlock. + cancelBackgroundInit() try { runBlocking { get().close() } } finally { diff --git a/androidApp/src/test/kotlin/org/meshtastic/app/CoilImageLoaderLifecycleTest.kt b/androidApp/src/test/kotlin/org/meshtastic/app/CoilImageLoaderLifecycleTest.kt index 77d17c4d11..44522659aa 100644 --- a/androidApp/src/test/kotlin/org/meshtastic/app/CoilImageLoaderLifecycleTest.kt +++ b/androidApp/src/test/kotlin/org/meshtastic/app/CoilImageLoaderLifecycleTest.kt @@ -33,7 +33,12 @@ import kotlin.test.assertSame class CoilImageLoaderLifecycleTest { @After @OptIn(DelicateCoilApi::class) - fun tearDown() = SingletonImageLoader.reset() + fun tearDown() { + // Booting the real Application starts background init on Dispatchers.Default; leaving it running + // leaks failures into later tests in this JVM (Robolectric never calls onTerminate). + ApplicationProvider.getApplicationContext().cancelBackgroundInit() + SingletonImageLoader.reset() + } @Test fun productionApplicationProvidesConfiguredKoinImageLoader() { diff --git a/androidApp/src/test/kotlin/org/meshtastic/app/ShareMessageDeepLinkTest.kt b/androidApp/src/test/kotlin/org/meshtastic/app/ShareMessageDeepLinkTest.kt index ae2b417268..e9a3c137a6 100644 --- a/androidApp/src/test/kotlin/org/meshtastic/app/ShareMessageDeepLinkTest.kt +++ b/androidApp/src/test/kotlin/org/meshtastic/app/ShareMessageDeepLinkTest.kt @@ -18,6 +18,8 @@ package org.meshtastic.app import android.app.PendingIntent import android.content.Intent +import androidx.test.core.app.ApplicationProvider +import org.junit.After import org.junit.runner.RunWith import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.navigation.ContactsRoute @@ -35,6 +37,13 @@ import kotlin.test.assertNull @Config(sdk = [34]) class ShareMessageDeepLinkTest { + @After + fun tearDown() { + // No `application =` override, so Robolectric boots the manifest's real MeshUtilApplication and its + // background init; stop it here or its failures land on whichever test runs next in this JVM. + ApplicationProvider.getApplicationContext().cancelBackgroundInit() + } + @Test fun `shared text round trips through the deep link query`() { val messages = diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/ProjectExtensions.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/ProjectExtensions.kt index 6f51a16ce5..35997720cf 100644 --- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/ProjectExtensions.kt +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/ProjectExtensions.kt @@ -127,7 +127,9 @@ internal fun Project.configureTestOptions() { extensions.findByType(DevelocityTestConfiguration::class.java)?.testRetry { maxRetries.set(MAX_TEST_RETRIES) maxFailures.set(MAX_TEST_FAILURES) - failOnPassedAfterRetry.set(false) + // Retry still isolates an ordering flake to one worker, but the build must not report success: + // a green tick over a recorded hid a real cross-test coroutine leak for weeks. + failOnPassedAfterRetry.set(true) } } } diff --git a/feature/connections/src/androidHostTest/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModelBondingTest.kt b/feature/connections/src/androidHostTest/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModelBondingTest.kt index 7f27f36293..52726b136c 100644 --- a/feature/connections/src/androidHostTest/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModelBondingTest.kt +++ b/feature/connections/src/androidHostTest/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModelBondingTest.kt @@ -90,6 +90,8 @@ class AndroidScannerViewModelBondingTest { @AfterTest fun tearDown() { + // Order matters: the ViewModel's coroutines must be gone before Main is unset. + harness.clearViewModel(viewModel) Dispatchers.resetMain() } diff --git a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelHarness.kt b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelHarness.kt index 51797f7ac6..7d841bd232 100644 --- a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelHarness.kt +++ b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelHarness.kt @@ -16,6 +16,8 @@ */ package org.meshtastic.feature.connections +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import dev.mokkery.MockMode import dev.mokkery.answering.returns import dev.mokkery.every @@ -23,6 +25,7 @@ import dev.mokkery.matcher.any import dev.mokkery.mock import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine @@ -136,6 +139,18 @@ class ScannerViewModelHarness(val testDispatcher: TestDispatcher = UnconfinedTes bleScanner = bleScanner, ) + /** + * Ends [viewModel]'s lifetime. Call from `@AfterTest` **before** `Dispatchers.resetMain()`. + * + * `viewModelScope` is never cleared for a hand-built ViewModel, so without this its coroutines outlive the test. + * One suspended on a real-dispatcher result (compose-resources resolves on an internal `Dispatchers.Default` scope) + * then resumes onto a `Dispatchers.Main` that `resetMain()` has already unset, which throws. Nothing handles it, so + * it lands as `UncaughtExceptionsBeforeTest` on whichever test starts next. + */ + fun clearViewModel(viewModel: ViewModel) { + viewModel.viewModelScope.cancel() + } + companion object { /** A scanned-but-unbonded BLE entry — the input that routes through `requestBonding`. */ fun unbondedBleEntry(address: String, name: String = "Node"): DeviceListEntry.Ble = diff --git a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt index c94867a839..dc8c7b215b 100644 --- a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt +++ b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt @@ -90,6 +90,8 @@ class ScannerViewModelTest { @AfterTest fun tearDown() { + // Order matters: the ViewModel's coroutines must be gone before Main is unset. + harness.clearViewModel(viewModel) Dispatchers.resetMain() }