mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-09-12 21:30:02 -04:00
fix(test): stop leaked coroutine scopes poisoning tests (#6683)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
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 =
|
||||
|
||||
+3
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
+15
@@ -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 =
|
||||
|
||||
+2
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user