fix(test): stop leaked coroutine scopes poisoning tests (#6683)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
James RichandClaude Opus 5 authored and GitHub committed 2026-08-13 18:45:33 +00:00
1 parent 6d67fb1513
commit fa6a21e997
7 files changed
+59 -6

No files matched your search

@@ -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<ImageLoader>()
@@ -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<DatabaseManager>().close() }
} finally {
@@ -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<MeshUtilApplication>().cancelBackgroundInit()
SingletonImageLoader.reset()
}
@Test
fun productionApplicationProvidesConfiguredKoinImageLoader() {
@@ -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<MeshUtilApplication>().cancelBackgroundInit()
}
@Test
fun `shared text round trips through the deep link query`() {
val messages =
@@ -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 <failure> hid a real cross-test coroutine leak for weeks.
failOnPassedAfterRetry.set(true)
}
}
}
@@ -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()
}
@@ -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 =
@@ -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()
}