From 6ee418f4858098e3a5f5c232933d2ef05fbba8b2 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 17 Jul 2026 14:12:29 -0300 Subject: [PATCH 1/2] refactor: log uncatch exceptions --- app/src/main/java/to/bitkit/App.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/src/main/java/to/bitkit/App.kt b/app/src/main/java/to/bitkit/App.kt index 859d4ba71b..dfe2b21370 100644 --- a/app/src/main/java/to/bitkit/App.kt +++ b/app/src/main/java/to/bitkit/App.kt @@ -14,6 +14,7 @@ import to.bitkit.appwidget.AppWidgetRefreshReason import to.bitkit.appwidget.AppWidgetRefreshScheduler import to.bitkit.env.Env import to.bitkit.services.BluetoothInit +import to.bitkit.utils.Logger import javax.inject.Inject @HiltAndroidApp @@ -34,6 +35,7 @@ internal open class App : Application(), Configuration.Provider { override fun onCreate() { super.onCreate() + installUncaughtExceptionLogger() Env.initAppStoragePath(filesDir.absolutePath) SingletonImageLoader.setSafe { imageLoader } currentActivity = CurrentActivity().also { registerActivityLifecycleCallbacks(it) } @@ -42,7 +44,17 @@ internal open class App : Application(), Configuration.Provider { BluetoothInit.ensureInitialized() } + private fun installUncaughtExceptionLogger() { + val previous = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + Logger.error("Uncaught exception on thread '${thread.name}'", throwable, context = TAG) + previous?.uncaughtException(thread, throwable) + } + } + companion object { + private const val TAG = "App" + @SuppressLint("StaticFieldLeak") // Should be safe given its manual memory management internal var currentActivity: CurrentActivity? = null } From bdcad4aeb2905f408660c8c25a49a6e54940c002 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 17 Jul 2026 14:14:57 -0300 Subject: [PATCH 2/2] fix: guard coroutine scopes against uncaught crashes --- .../androidServices/LightningNodeService.kt | 5 ++- .../to/bitkit/async/BaseCoroutineScope.kt | 3 +- .../java/to/bitkit/async/CoroutineScopes.kt | 15 +++++++++ .../main/java/to/bitkit/async/ServiceQueue.kt | 5 +-- .../java/to/bitkit/data/keychain/Keychain.kt | 2 +- .../java/to/bitkit/repositories/BackupRepo.kt | 5 ++- .../to/bitkit/repositories/BlocktankRepo.kt | 5 ++- .../bitkit/repositories/ConnectivityRepo.kt | 6 ++-- .../to/bitkit/repositories/CurrencyRepo.kt | 5 ++- .../java/to/bitkit/repositories/HealthRepo.kt | 9 ++++-- .../to/bitkit/repositories/HwWalletRepo.kt | 5 ++- .../to/bitkit/repositories/LightningRepo.kt | 7 ++--- .../bitkit/repositories/PrivatePaykitRepo.kt | 5 ++- .../java/to/bitkit/repositories/PubkyRepo.kt | 5 ++- .../java/to/bitkit/repositories/TrezorRepo.kt | 5 ++- .../java/to/bitkit/repositories/WalletRepo.kt | 7 ++--- .../to/bitkit/repositories/WidgetsRepo.kt | 5 ++- .../to/bitkit/services/LightningService.kt | 4 +-- .../ui/screens/trezor/TrezorViewModel.kt | 9 ++++-- .../to/bitkit/async/CoroutineScopesTest.kt | 31 +++++++++++++++++++ .../java/to/bitkit/async/ServiceQueueTest.kt | 30 ++++++++++++++++++ changelog.d/next/1094.fixed.md | 1 + 22 files changed, 124 insertions(+), 50 deletions(-) create mode 100644 app/src/main/java/to/bitkit/async/CoroutineScopes.kt create mode 100644 app/src/test/java/to/bitkit/async/CoroutineScopesTest.kt create mode 100644 app/src/test/java/to/bitkit/async/ServiceQueueTest.kt create mode 100644 changelog.d/next/1094.fixed.md diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index fd70ca71e8..a1d248de4b 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -14,14 +14,13 @@ import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.Event import to.bitkit.App import to.bitkit.R import to.bitkit.appwidget.AppWidgetRefreshReason import to.bitkit.appwidget.AppWidgetRefreshScheduler +import to.bitkit.async.appScope import to.bitkit.data.CacheStore import to.bitkit.di.UiDispatcher import to.bitkit.domain.commands.NotifyChannelReady @@ -53,7 +52,7 @@ class LightningNodeService : Service() { @UiDispatcher lateinit var uiDispatcher: CoroutineDispatcher - private val serviceScope by lazy { CoroutineScope(SupervisorJob() + uiDispatcher) } + private val serviceScope by lazy { appScope(uiDispatcher, TAG) } @Inject lateinit var lightningRepo: LightningRepo diff --git a/app/src/main/java/to/bitkit/async/BaseCoroutineScope.kt b/app/src/main/java/to/bitkit/async/BaseCoroutineScope.kt index 7a3c9d459b..e114e37874 100644 --- a/app/src/main/java/to/bitkit/async/BaseCoroutineScope.kt +++ b/app/src/main/java/to/bitkit/async/BaseCoroutineScope.kt @@ -7,7 +7,8 @@ import kotlin.coroutines.CoroutineContext open class BaseCoroutineScope( private val dispatcher: CoroutineDispatcher, -) : CoroutineScope by CoroutineScope(SupervisorJob() + dispatcher) { + tag: String, +) : CoroutineScope by CoroutineScope(SupervisorJob() + dispatcher + loggingExceptionHandler(tag)) { @Throws(InterruptedException::class) protected fun runBlocking( diff --git a/app/src/main/java/to/bitkit/async/CoroutineScopes.kt b/app/src/main/java/to/bitkit/async/CoroutineScopes.kt new file mode 100644 index 0000000000..f340ed6ad4 --- /dev/null +++ b/app/src/main/java/to/bitkit/async/CoroutineScopes.kt @@ -0,0 +1,15 @@ +package to.bitkit.async + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import to.bitkit.utils.Logger + +fun loggingExceptionHandler(tag: String): CoroutineExceptionHandler = + CoroutineExceptionHandler { _, throwable -> + Logger.error("Uncaught coroutine exception", throwable, context = tag) + } + +fun appScope(dispatcher: CoroutineDispatcher, tag: String): CoroutineScope = + CoroutineScope(dispatcher + SupervisorJob() + loggingExceptionHandler(tag)) diff --git a/app/src/main/java/to/bitkit/async/ServiceQueue.kt b/app/src/main/java/to/bitkit/async/ServiceQueue.kt index 68d339b7f4..ea3b4ad0a8 100644 --- a/app/src/main/java/to/bitkit/async/ServiceQueue.kt +++ b/app/src/main/java/to/bitkit/async/ServiceQueue.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import to.bitkit.ext.runSuspendCatching import to.bitkit.utils.AppError import java.util.concurrent.Executors import java.util.concurrent.ThreadFactory @@ -20,14 +21,14 @@ enum class ServiceQueue { coroutineContext: CoroutineContext = scope.coroutineContext, block: suspend CoroutineScope.() -> T, ): T = runBlocking(coroutineContext) { - runCatching { block() }.getOrElse { throw AppError(it) } + runSuspendCatching { block() }.getOrElse { throw AppError(it) } } suspend fun background( coroutineContext: CoroutineContext = scope.coroutineContext, block: suspend CoroutineScope.() -> T, ): T = withContext(coroutineContext) { - runCatching { block() }.getOrElse { throw AppError(it) } + runSuspendCatching { block() }.getOrElse { throw AppError(it) } } } diff --git a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt index f50a44b425..7eb9431a75 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -34,7 +34,7 @@ class Keychain @Inject constructor( private val db: AppDb, @ApplicationContext private val context: Context, @IoDispatcher private val dispatcher: CoroutineDispatcher, -) : BaseCoroutineScope(dispatcher) { +) : BaseCoroutineScope(dispatcher, TAG) { companion object { private const val TAG = "Keychain" private const val CAUSE_CHAIN_DEPTH = 4 diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 0d681c7487..f2a5a682e0 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -6,10 +6,8 @@ import com.synonym.bitkitcore.migrateBackupActivityTagsJson import com.synonym.bitkitcore.migrateBackupPreActivityMetadataJson import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.currentCoroutineContext @@ -33,6 +31,7 @@ import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonObject import to.bitkit.R +import to.bitkit.async.appScope import to.bitkit.data.AppDb import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore @@ -102,7 +101,7 @@ class BackupRepo @Inject constructor( private val clock: Clock, private val db: AppDb, ) { - private val scope = CoroutineScope(ioDispatcher + SupervisorJob()) + private val scope = appScope(ioDispatcher, TAG) private val backupJobs = mutableMapOf() private val statusObserverJobs = mutableListOf() diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index 4ca4164b3b..8225bf592f 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -21,9 +21,7 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope @@ -50,6 +48,7 @@ import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.Event import to.bitkit.async.ServiceQueue +import to.bitkit.async.appScope import to.bitkit.data.CacheStore import to.bitkit.di.BgDispatcher import to.bitkit.env.Env @@ -82,7 +81,7 @@ class BlocktankRepo @Inject constructor( @Named("enablePolling") private val enablePolling: Boolean, private val lightningRepo: LightningRepo, ) { - private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob()) + private val repoScope = appScope(bgDispatcher, TAG) private val _blocktankState = MutableStateFlow(BlocktankState()) val blocktankState: StateFlow = _blocktankState.asStateFlow() diff --git a/app/src/main/java/to/bitkit/repositories/ConnectivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ConnectivityRepo.kt index f1ce6d1beb..a7cce43d28 100644 --- a/app/src/main/java/to/bitkit/repositories/ConnectivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ConnectivityRepo.kt @@ -9,8 +9,6 @@ import android.os.Handler import android.os.Looper import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -19,6 +17,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import to.bitkit.async.appScope import to.bitkit.di.BgDispatcher import to.bitkit.utils.Logger import javax.inject.Inject @@ -29,11 +28,12 @@ class ConnectivityRepo @Inject constructor( @ApplicationContext private val context: Context, @BgDispatcher private val bgDispatcher: CoroutineDispatcher, ) { - private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob()) + private val repoScope = appScope(bgDispatcher, TAG) private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager private val mainHandler = Handler(Looper.getMainLooper()) companion object { + private const val TAG = "ConnectivityRepo" private const val DELAY_MS = 250L } diff --git a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt index eac98c6cf8..f344344b33 100644 --- a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt @@ -5,8 +5,6 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -22,6 +20,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import to.bitkit.async.appScope import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher @@ -58,7 +57,7 @@ class CurrencyRepo @Inject constructor( private val clock: Clock, @Named("enablePolling") private val enablePolling: Boolean, ) : AmountInputHandler { - private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob()) + private val repoScope = appScope(bgDispatcher, TAG) private val _currencyState = MutableStateFlow(CurrencyState()) val currencyState: StateFlow = _currencyState.asStateFlow() diff --git a/app/src/main/java/to/bitkit/repositories/HealthRepo.kt b/app/src/main/java/to/bitkit/repositories/HealthRepo.kt index 42c665cb51..2c2f6b0f09 100644 --- a/app/src/main/java/to/bitkit/repositories/HealthRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HealthRepo.kt @@ -1,8 +1,6 @@ package to.bitkit.repositories import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -12,6 +10,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.ChannelDetails +import to.bitkit.async.appScope import to.bitkit.data.CacheStore import to.bitkit.di.BgDispatcher import to.bitkit.models.BackupCategory @@ -33,7 +32,11 @@ class HealthRepo @Inject constructor( private val cacheStore: CacheStore, private val clock: Clock, ) { - private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob()) + companion object { + private const val TAG = "HealthRepo" + } + + private val repoScope = appScope(bgDispatcher, TAG) private val _healthState = MutableStateFlow(AppHealthState()) val healthState: StateFlow = _healthState.asStateFlow() diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 0c08b0c63e..564fb800df 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -13,8 +13,6 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -30,6 +28,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import to.bitkit.async.appScope import to.bitkit.data.HwWalletStore import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher @@ -90,7 +89,7 @@ class HwWalletRepo @Inject constructor( private val SUPPORTED_WATCHER_ADDRESS_TYPES = setOf(HwFundingAddressType.NATIVE_SEGWIT.settingsKey) } - private val scope = CoroutineScope(SupervisorJob() + ioDispatcher) + private val scope = appScope(ioDispatcher, TAG) private val activeWatchers = mutableSetOf() private val activeWatcherElectrumUrls = mutableMapOf() diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 1e8800355c..642721e1f5 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -17,9 +17,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -54,6 +52,7 @@ import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.PeerDetails import org.lightningdevkit.ldknode.SpendableUtxo import org.lightningdevkit.ldknode.Txid +import to.bitkit.async.appScope import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore @@ -73,6 +72,7 @@ import to.bitkit.models.NATIVE_WITNESS_TYPES import to.bitkit.models.NodeLifecycleState import to.bitkit.models.OpenChannelResult import to.bitkit.models.TransactionSpeed +import to.bitkit.models.WalletScope import to.bitkit.models.safe import to.bitkit.models.satsToMsat import to.bitkit.models.toAddressType @@ -88,7 +88,6 @@ import to.bitkit.services.LnurlWithdrawResponse import to.bitkit.services.LspNotificationsService import to.bitkit.services.NodeEventHandler import to.bitkit.utils.AppError -import to.bitkit.models.WalletScope import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError import to.bitkit.utils.UrlValidator @@ -128,7 +127,7 @@ class LightningRepo @Inject constructor( private val _nodeEvents = MutableSharedFlow(extraBufferCapacity = 64) val nodeEvents = _nodeEvents.asSharedFlow() - private val scope = CoroutineScope(bgDispatcher + SupervisorJob()) + private val scope = appScope(bgDispatcher, TAG) private val _eventHandlers = ConcurrentHashMap.newKeySet() private val _isRecoveryMode = MutableStateFlow(false) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 135d67bdcc..21851d4fef 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -9,10 +9,8 @@ import com.synonym.paykit.PrivatePaymentListDeliveryReport import com.synonym.paykit.PrivatePaymentListReservationUpdateInput import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -27,6 +25,7 @@ import org.lightningdevkit.ldknode.PaymentDirection import org.lightningdevkit.ldknode.PaymentKind import org.lightningdevkit.ldknode.PaymentStatus import to.bitkit.App +import to.bitkit.async.appScope import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher @@ -86,7 +85,7 @@ class PrivatePaykitRepo @Inject constructor( private val publicationMutex = Mutex() private val serializedDispatcher = ioDispatcher.limitedParallelism(1) - private val retryScope = CoroutineScope(serializedDispatcher + SupervisorJob()) + private val retryScope = appScope(serializedDispatcher, TAG) private val knownSavedContactKeys = mutableSetOf() private var state: PrivatePaykitState? = null private val pendingMessageDrainRetryLock = Any() diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 9557ed240c..baa0dcff05 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -11,9 +11,7 @@ import io.ktor.client.call.body import io.ktor.client.request.post import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -33,6 +31,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import to.bitkit.async.appScope import to.bitkit.data.PubkyStore import to.bitkit.data.SettingsStore import to.bitkit.data.hasPublicPaykitPublicationState @@ -94,7 +93,7 @@ class PubkyRepo @Inject constructor( private const val AVATAR_QUALITY = 80 } - private val scope = CoroutineScope(ioDispatcher + SupervisorJob()) + private val scope = appScope(ioDispatcher, TAG) private val serviceInitializeMutex = Mutex() private val initializeMutex = Mutex() private val loadProfileMutex = Mutex() diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 8ac1839b82..aa5fcdda79 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -33,10 +33,8 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow @@ -53,6 +51,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import to.bitkit.async.appScope import to.bitkit.data.HwWalletStore import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher @@ -126,7 +125,7 @@ class TrezorRepo @Inject constructor( private val _state = MutableStateFlow(TrezorState()) val state = _state.asStateFlow() - private val scope = CoroutineScope(SupervisorJob() + ioDispatcher) + private val scope = appScope(ioDispatcher, TAG) private var isSetup = CompletableDeferred() private val setupMutex = Mutex() diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 681f7bd74f..d129a13295 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -10,9 +10,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -23,6 +21,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.WordCount +import to.bitkit.async.appScope import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain @@ -36,6 +35,7 @@ import to.bitkit.models.ALL_ADDRESS_TYPE_STRINGS import to.bitkit.models.AddressModel import to.bitkit.models.BalanceState import to.bitkit.models.DEFAULT_ADDRESS_TYPE_STRING +import to.bitkit.models.WalletScope import to.bitkit.models.msatFloorOf import to.bitkit.models.toAccountDerivationPath import to.bitkit.models.toBalance @@ -45,7 +45,6 @@ import to.bitkit.services.CoreService import to.bitkit.usecases.DeriveBalanceStateUseCase import to.bitkit.usecases.WipeWalletUseCase import to.bitkit.utils.Bip21Utils -import to.bitkit.models.WalletScope import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError import to.bitkit.utils.measured @@ -71,7 +70,7 @@ class WalletRepo @Inject constructor( private val activityRepo: ActivityRepo, private val hwWalletRepo: HwWalletRepo, ) { - private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob()) + private val repoScope = appScope(bgDispatcher, TAG) private val _walletState = MutableStateFlow(WalletState(walletExists = walletExists())) val walletState = _walletState.asStateFlow() diff --git a/app/src/main/java/to/bitkit/repositories/WidgetsRepo.kt b/app/src/main/java/to/bitkit/repositories/WidgetsRepo.kt index 121cad7876..c5a6ed19ed 100644 --- a/app/src/main/java/to/bitkit/repositories/WidgetsRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WidgetsRepo.kt @@ -1,9 +1,7 @@ package to.bitkit.repositories import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -18,6 +16,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import to.bitkit.async.appScope import to.bitkit.data.WidgetsData import to.bitkit.data.WidgetsStore import to.bitkit.data.dto.ArticleDTO @@ -57,7 +56,7 @@ class WidgetsRepo @Inject constructor( private val priceService: PriceService, private val widgetsStore: WidgetsStore, ) { - private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob()) + private val repoScope = appScope(bgDispatcher, TAG) private val widgetJobs = ConcurrentHashMap() val widgetsDataFlow: StateFlow = widgetsStore.data diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index c58b1151cf..c240b5da0e 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -85,7 +85,7 @@ class LightningService @Inject constructor( private val vssStoreIdProvider: VssStoreIdProvider, private val settingsStore: SettingsStore, private val loggerLdk: LoggerLdk, -) : BaseCoroutineScope(bgDispatcher) { +) : BaseCoroutineScope(bgDispatcher, TAG) { companion object { private const val TAG = "LightningService" @@ -886,7 +886,7 @@ class LightningService @Inject constructor( liquidityPenaltyMultiplierMsat = SCORING_LIQUIDITY_PENALTY_MULTIPLIER_MSAT, liquidityPenaltyAmountMultiplierMsat = SCORING_LIQUIDITY_PENALTY_AMOUNT_MULTIPLIER_MSAT, historicalLiquidityPenaltyAmountMultiplierMsat = - SCORING_HISTORICAL_LIQUIDITY_PENALTY_AMOUNT_MULTIPLIER_MSAT, + SCORING_HISTORICAL_LIQUIDITY_PENALTY_AMOUNT_MULTIPLIER_MSAT, consideredImpossiblePenaltyMsat = SCORING_CONSIDERED_IMPOSSIBLE_PENALTY_MSAT, probingDiversityPenaltyMsat = SCORING_PROBING_DIVERSITY_PENALTY_MSAT, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt index 624c316a75..e79abfdef6 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt @@ -21,14 +21,13 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import to.bitkit.async.appScope import to.bitkit.di.BgDispatcher import to.bitkit.env.Env import to.bitkit.models.HwWalletId @@ -51,10 +50,14 @@ class TrezorViewModel @Inject constructor( private val trezorRepo: TrezorRepo, ) : ViewModel() { + companion object { + private const val TAG = "TrezorViewModel" + } + @Volatile private var isCleared = false - private val watcherStartScope = CoroutineScope(SupervisorJob() + bgDispatcher) + private val watcherStartScope = appScope(bgDispatcher, TAG) init { observeWatcherEvents() diff --git a/app/src/test/java/to/bitkit/async/CoroutineScopesTest.kt b/app/src/test/java/to/bitkit/async/CoroutineScopesTest.kt new file mode 100644 index 0000000000..a06d65cbcd --- /dev/null +++ b/app/src/test/java/to/bitkit/async/CoroutineScopesTest.kt @@ -0,0 +1,31 @@ +package to.bitkit.async + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import org.junit.Test +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class CoroutineScopesTest : BaseUnitTest() { + + @Test + fun `appScope stays active after an uncaught exception in a child`() = test { + val scope = appScope(testDispatcher, "Test") + + scope.launch { throw IllegalStateException("boom") } + runCurrent() + + assertTrue(scope.isActive) + + var secondChildRan = false + scope.launch { secondChildRan = true } + runCurrent() + + assertTrue(secondChildRan) + scope.cancel() + } +} diff --git a/app/src/test/java/to/bitkit/async/ServiceQueueTest.kt b/app/src/test/java/to/bitkit/async/ServiceQueueTest.kt new file mode 100644 index 0000000000..4773435eba --- /dev/null +++ b/app/src/test/java/to/bitkit/async/ServiceQueueTest.kt @@ -0,0 +1,30 @@ +package to.bitkit.async + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Test +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError +import kotlin.test.assertFailsWith + +@OptIn(ExperimentalCoroutinesApi::class) +class ServiceQueueTest : BaseUnitTest() { + + @Test + fun `background rethrows CancellationException without wrapping it`() = test { + assertFailsWith { + ServiceQueue.CORE.background(testDispatcher) { + throw CancellationException("cancelled") + } + } + } + + @Test + fun `background wraps non-cancellation failures in AppError`() = test { + assertFailsWith { + ServiceQueue.CORE.background(testDispatcher) { + throw IllegalStateException("boom") + } + } + } +} diff --git a/changelog.d/next/1094.fixed.md b/changelog.d/next/1094.fixed.md new file mode 100644 index 0000000000..4f30df97c9 --- /dev/null +++ b/changelog.d/next/1094.fixed.md @@ -0,0 +1 @@ +Hardened background task error handling to prevent rare crashes.