From d0b1e99d10d2fc51380d52817ab3047ccaa29381 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 10 Nov 2026 07:15:19 -0300 Subject: [PATCH 1/5] fix: exit instead of releasing a node with detached tasks Co-Authored-By: Claude Opus 5.5 --- .../to/bitkit/services/LightningService.kt | 71 ++++++++++++++++++- .../main/java/to/bitkit/utils/LoggerLdk.kt | 2 + .../bitkit/services/LightningServiceTest.kt | 46 ++++++++++++ .../bitkit/services/NodeRuntimeMonitorTest.kt | 65 +++++++++++++++++ changelog.d/next/1536.fixed.md | 1 + 5 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/to/bitkit/services/NodeRuntimeMonitorTest.kt create mode 100644 changelog.d/next/1536.fixed.md diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index c0ff8e83b1..2c15fb54a2 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -1,11 +1,15 @@ package to.bitkit.services +import android.os.Process +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ProcessLifecycleOwner import com.synonym.bitkitcore.AddressType import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow @@ -36,6 +40,7 @@ import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.FeeRate import org.lightningdevkit.ldknode.KeychainKind import org.lightningdevkit.ldknode.LightningBalance +import org.lightningdevkit.ldknode.LogRecord import org.lightningdevkit.ldknode.Node import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.NodeStatus @@ -93,6 +98,7 @@ import to.bitkit.utils.LoggerLdk import to.bitkit.utils.ServiceError import to.bitkit.utils.jsonLogOf import java.io.File +import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject import javax.inject.Singleton import kotlin.coroutines.AbstractCoroutineContextElement @@ -101,9 +107,11 @@ import kotlin.coroutines.cancellation.CancellationException import kotlin.io.path.Path import kotlin.time.Clock import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant import org.lightningdevkit.ldknode.AddressType as LdkAddressType +import org.lightningdevkit.ldknode.LogLevel as LdkLogLevel typealias NodeEventHandler = suspend (Event) -> Unit @@ -196,6 +204,8 @@ class LightningService internal constructor( private val ldkQueue: CoroutineContext, private val onchainFeeRateFactory: (ULong) -> FeeRate = { FeeRate.fromSatPerVbUnchecked(it) }, private val clock: Clock = Clock.System, + private val isAppInBackground: () -> Boolean = ::isProcessInBackground, + private val exitProcess: () -> Unit = { Process.killProcess(Process.myPid()) }, ) : BaseCoroutineScope(bgDispatcher, TAG) { companion object { @@ -226,6 +236,9 @@ class LightningService internal constructor( */ private val NODE_RELEASE_GATE_TIMEOUT = 90.seconds + /** Time given to the async log writer to persist the exit reason before the process is killed. */ + private val EXIT_LOG_FLUSH_DELAY = 500.milliseconds + private val DEFAULT_SCORING_FEE_PARAMETERS = ScoringFeeParameters( basePenaltyMsat = 1_024uL, basePenaltyAmountMultiplierMsat = 131_072uL, @@ -281,6 +294,10 @@ class LightningService internal constructor( @Volatile private var releaseJob: Job? = null + // Watches the current node's ldk-node logs for tracked tasks left detached by a timed-out stop. + @Volatile + internal var runtimeMonitor = NodeRuntimeMonitor() + suspend fun setup( walletIndex: Int, customServerUrl: String? = null, @@ -291,14 +308,17 @@ class LightningService internal constructor( Logger.debug("Building node…", context = TAG) val config = config(walletIndex) + val monitor = NodeRuntimeMonitor() val builtNode = build( walletIndex, customServerUrl, customRgsServerUrl, config, + monitor, channelMigration, ) currentWalletIndex = walletIndex + runtimeMonitor = monitor node = builtNode Logger.info("LDK node setup", context = TAG) @@ -320,6 +340,7 @@ class LightningService internal constructor( customServerUrl: String?, customRgsServerUrl: String?, config: Config, + runtimeMonitor: NodeRuntimeMonitor, channelMigration: ChannelDataMigration? = null, ): Node = ServiceQueue.LDK.background(ldkQueue) { val storedSettings = settingsStore.data.first() @@ -335,7 +356,7 @@ class LightningService internal constructor( .map { it.toLdkAddressType() } val builder = Builder.fromConfig(config).apply { - setCustomLogger(LdkLogWriter()) + setCustomLogger(LdkLogWriter(onRecord = runtimeMonitor::onLog)) configureChainSource(customServerUrl) configureGossipSource(customRgsServerUrl) configureScorerSource() @@ -528,6 +549,7 @@ class LightningService internal constructor( Logger.debug("Node already stopped", context = TAG) return@withContext } + val monitor = runtimeMonitor Logger.debug("Stopping node…", context = TAG) ServiceQueue.LDK.background(ldkQueue) { @@ -537,10 +559,30 @@ class LightningService internal constructor( } this@LightningService.node = null } + if (monitor.hasDetachedWork && isAppInBackground()) { + exitBeforeRuntimeShutdown() + return@withContext + } releaseHandle(node) Logger.info("Node stopped", context = TAG) } + /** + * Ends the process instead of releasing a node whose stop left tracked tasks detached. + * + * Releasing the node shuts down its tokio runtime underneath those tasks, and the first one that + * resumes (a VSS write returning, or the process thawing from the cached-apps freezer) polls a + * timer on the dead runtime and aborts the app with "A Tokio 1.x context was found, but it is + * being shutdown." Killing the process while the runtime is still alive avoids that abort, and a + * backgrounded app loses nothing a cold start does not restore. In the foreground the release + * still runs, since ending the process there would close the app in front of the user. + */ + private suspend fun exitBeforeRuntimeShutdown() { + Logger.warn("Exiting process: node stop left ldk_node tasks detached while in background", context = TAG) + delay(EXIT_LOG_FLUSH_DELAY) + exitProcess() + } + /** * Releases the rust handle instead of leaving it to the GC finalizer, keeping it off the * single-threaded LDK queue and off the lifecycle critical path. @@ -1578,3 +1620,30 @@ internal fun exactOriginalSendFee( check(inputTotal >= outputTotal) return inputTotal - outputTotal } + +private fun isProcessInBackground(): Boolean = + !ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + +/** + * Flags a node whose stop timed out and left tracked tasks detached on its tokio runtime. + * + * ldk-node reports this only through its log, as an `ERROR` from `ldk_node::runtime` starting with + * "Detaching" (background tasks, cancellable background tasks, or the event processor). + */ +internal class NodeRuntimeMonitor { + companion object { + private const val RUNTIME_MODULE = "ldk_node::runtime" + private const val DETACHING_PREFIX = "Detaching " + } + + private val detached = AtomicBoolean(false) + + val hasDetachedWork: Boolean get() = detached.get() + + fun onLog(record: LogRecord) { + if (record.level != LdkLogLevel.ERROR) return + if (!record.modulePath.startsWith(RUNTIME_MODULE)) return + if (!record.args.startsWith(DETACHING_PREFIX)) return + detached.set(true) + } +} diff --git a/app/src/main/java/to/bitkit/utils/LoggerLdk.kt b/app/src/main/java/to/bitkit/utils/LoggerLdk.kt index 7c8fc8d52a..18c6771c53 100644 --- a/app/src/main/java/to/bitkit/utils/LoggerLdk.kt +++ b/app/src/main/java/to/bitkit/utils/LoggerLdk.kt @@ -181,10 +181,12 @@ class LoggerLdk @Inject constructor( class LdkLogWriter( private val maxLogLevel: LdkLogLevel = Env.ldkLogLevel, saver: LogSaver = AppLogger.getOrCreateSaver(), + private val onRecord: (LogRecord) -> Unit = {}, ) : LogWriter { private val delegate: LoggerImpl = LoggerImpl(LDK, saver) override fun log(record: LogRecord) { + onRecord(record) if (record.level < maxLogLevel) return val msg = record.args diff --git a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt index ca4faba2ef..c9f508800d 100644 --- a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt @@ -32,6 +32,7 @@ import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.FeeRate import org.lightningdevkit.ldknode.KeychainKind import org.lightningdevkit.ldknode.LightningBalance +import org.lightningdevkit.ldknode.LogRecord import org.lightningdevkit.ldknode.Network import org.lightningdevkit.ldknode.Node import org.lightningdevkit.ldknode.NodeException @@ -101,12 +102,19 @@ import kotlin.time.Clock import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Instant import com.synonym.bitkitcore.AddressType as BitkitAddressType +import org.lightningdevkit.ldknode.LogLevel as LdkLogLevel import org.lightningdevkit.ldknode.PreparedOnchainSend as NativePreparedOnchainSend private const val VERIFY_TIMEOUT_MS = 2_000L private const val RELEASE_GATE_PROBE_MS = 200L private const val STOP_BOUND_MS = 5_000L private val SHORT_GATE_TIMEOUT = 200.milliseconds +private val detachingRecord = LogRecord( + LdkLogLevel.ERROR, + "Detaching background tasks after cancellation timed out.", + "ldk_node::runtime", + 362u, +) class LightningServiceTest : BaseUnitTest() { private val keychain = mock() @@ -123,6 +131,8 @@ class LightningServiceTest : BaseUnitTest() { val tempFolder = TemporaryFolder() private lateinit var sut: LightningService + private var isAppInBackground = false + private var exitCount = 0 @Before fun setUp() { @@ -141,6 +151,8 @@ class LightningServiceTest : BaseUnitTest() { watchOnlyAccountLifecycleCoordinator = watchOnlyAccountLifecycleCoordinator, ldkQueue = ldkDispatcher, onchainFeeRateFactory = { sendFeeRate }, + isAppInBackground = { isAppInBackground }, + exitProcess = { exitCount++ }, ).also { it.node = node } @OptIn(ExperimentalCoroutinesApi::class) @@ -631,6 +643,40 @@ class LightningServiceTest : BaseUnitTest() { assertNull(sut.node) } + // Regression #1536: releasing the node shuts down its runtime under the detached tasks, which abort the app + @Test + fun `stop exits the process instead of releasing a node with detached work in background`() = test { + isAppInBackground = true + sut.runtimeMonitor.onLog(detachingRecord) + + sut.stop() + + verify(node).stop() + verify(node, never()).destroy() + assertEquals(1, exitCount) + assertNull(sut.node) + } + + @Test + fun `stop releases a node with detached work in foreground`() = test { + sut.runtimeMonitor.onLog(detachingRecord) + + sut.stop() + + verify(node).destroy() + assertEquals(0, exitCount) + } + + @Test + fun `stop releases a node without detached work in background`() = test { + isAppInBackground = true + + sut.stop() + + verify(node).destroy() + assertEquals(0, exitCount) + } + @Test fun `stop is a no-op when no node is set`() = test { sut.node = null diff --git a/app/src/test/java/to/bitkit/services/NodeRuntimeMonitorTest.kt b/app/src/test/java/to/bitkit/services/NodeRuntimeMonitorTest.kt new file mode 100644 index 0000000000..302e9369a5 --- /dev/null +++ b/app/src/test/java/to/bitkit/services/NodeRuntimeMonitorTest.kt @@ -0,0 +1,65 @@ +package to.bitkit.services + +import org.junit.Test +import org.lightningdevkit.ldknode.LogRecord +import to.bitkit.utils.LdkLogWriter +import to.bitkit.utils.LogSaver +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.lightningdevkit.ldknode.LogLevel as LdkLogLevel + +class NodeRuntimeMonitorTest { + private val sut = NodeRuntimeMonitor() + + @Test + fun `flags each detaching message ldk-node logs after a timed-out stop`() { + listOf( + "Detaching background tasks after cancellation timed out.", + "Detaching cancellable background tasks after cancellation timed out.", + "Detaching event processor after cancellation timed out: deadline has elapsed", + ).forEach { + val monitor = NodeRuntimeMonitor() + + monitor.onLog(record(it)) + + assertTrue(monitor.hasDetachedWork, it) + } + } + + @Test + fun `ignores a stop that timed out but did not detach`() { + sut.onLog(record("Stopping background tasks timed out.")) + sut.onLog(record("Stopping event handling timed out: deadline has elapsed")) + + assertFalse(sut.hasDetachedWork) + } + + @Test + fun `ignores detaching messages from other modules or levels`() { + sut.onLog(record("Detaching background tasks after cancellation timed out.", module = "ldk_node::chain")) + sut.onLog(record("Detaching background tasks after cancellation timed out.", level = LdkLogLevel.WARN)) + + assertFalse(sut.hasDetachedWork) + } + + @Test + fun `log writer forwards records to the monitor`() { + val writer = LdkLogWriter( + maxLogLevel = LdkLogLevel.ERROR, + saver = object : LogSaver { + override fun save(message: String) = Unit + }, + onRecord = sut::onLog, + ) + + writer.log(record("Detaching background tasks after cancellation timed out.")) + + assertTrue(sut.hasDetachedWork) + } + + private fun record( + args: String, + module: String = "ldk_node::runtime", + level: LdkLogLevel = LdkLogLevel.ERROR, + ) = LogRecord(level, args, module, 1u) +} diff --git a/changelog.d/next/1536.fixed.md b/changelog.d/next/1536.fixed.md new file mode 100644 index 0000000000..ebc23274a8 --- /dev/null +++ b/changelog.d/next/1536.fixed.md @@ -0,0 +1 @@ +Fix a rare crash when Bitkit returned from the background after the Lightning node was slow to stop. From b04e632346b14f4ad813315190ce308a234fda4b Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 10 Nov 2026 13:04:14 -0300 Subject: [PATCH 2/5] refactor: move exit rationale out of private kdoc Co-Authored-By: Claude Opus 5.5 --- .../java/to/bitkit/services/LightningService.kt | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 2c15fb54a2..ac464e6134 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -559,6 +559,10 @@ class LightningService internal constructor( } this@LightningService.node = null } + // Releasing a node whose stop left tasks detached shuts down its tokio runtime under them, and the first + // one to resume (a VSS write returning, or a thaw from the freezer) aborts the app with "A Tokio 1.x + // context was found, but it is being shutdown." In the background, kill the process while the runtime + // is alive instead; a cold start restores everything. In the foreground, release as before. if (monitor.hasDetachedWork && isAppInBackground()) { exitBeforeRuntimeShutdown() return@withContext @@ -567,16 +571,6 @@ class LightningService internal constructor( Logger.info("Node stopped", context = TAG) } - /** - * Ends the process instead of releasing a node whose stop left tracked tasks detached. - * - * Releasing the node shuts down its tokio runtime underneath those tasks, and the first one that - * resumes (a VSS write returning, or the process thawing from the cached-apps freezer) polls a - * timer on the dead runtime and aborts the app with "A Tokio 1.x context was found, but it is - * being shutdown." Killing the process while the runtime is still alive avoids that abort, and a - * backgrounded app loses nothing a cold start does not restore. In the foreground the release - * still runs, since ending the process there would close the app in front of the user. - */ private suspend fun exitBeforeRuntimeShutdown() { Logger.warn("Exiting process: node stop left ldk_node tasks detached while in background", context = TAG) delay(EXIT_LOG_FLUSH_DELAY) From 10effce4438486da4e3df8dd09e3e355ec3aabe2 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 10 Nov 2026 13:14:13 -0300 Subject: [PATCH 3/5] fix: skip the exit when the app returns during the delay Co-Authored-By: Claude Opus 5.5 --- .../to/bitkit/services/LightningService.kt | 12 ++++++----- .../bitkit/services/LightningServiceTest.kt | 20 +++++++++++++++++-- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index ac464e6134..6fe8f25e31 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -563,18 +563,20 @@ class LightningService internal constructor( // one to resume (a VSS write returning, or a thaw from the freezer) aborts the app with "A Tokio 1.x // context was found, but it is being shutdown." In the background, kill the process while the runtime // is alive instead; a cold start restores everything. In the foreground, release as before. - if (monitor.hasDetachedWork && isAppInBackground()) { - exitBeforeRuntimeShutdown() - return@withContext - } + if (monitor.hasDetachedWork && isAppInBackground() && exitBeforeRuntimeShutdown()) return@withContext releaseHandle(node) Logger.info("Node stopped", context = TAG) } - private suspend fun exitBeforeRuntimeShutdown() { + private suspend fun exitBeforeRuntimeShutdown(): Boolean { Logger.warn("Exiting process: node stop left ldk_node tasks detached while in background", context = TAG) delay(EXIT_LOG_FLUSH_DELAY) + if (!isAppInBackground()) { + Logger.warn("Cancelled process exit: app returned to foreground", context = TAG) + return false + } exitProcess() + return true } /** diff --git a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt index c9f508800d..ab51a12ad9 100644 --- a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt @@ -139,7 +139,10 @@ class LightningServiceTest : BaseUnitTest() { sut = createService() } - private fun createService(ldkDispatcher: CoroutineDispatcher = testDispatcher): LightningService = + private fun createService( + ldkDispatcher: CoroutineDispatcher = testDispatcher, + isAppInBackground: () -> Boolean = { this.isAppInBackground }, + ): LightningService = LightningService( bgDispatcher = testDispatcher, ioDispatcher = testDispatcher, @@ -151,7 +154,7 @@ class LightningServiceTest : BaseUnitTest() { watchOnlyAccountLifecycleCoordinator = watchOnlyAccountLifecycleCoordinator, ldkQueue = ldkDispatcher, onchainFeeRateFactory = { sendFeeRate }, - isAppInBackground = { isAppInBackground }, + isAppInBackground = isAppInBackground, exitProcess = { exitCount++ }, ).also { it.node = node } @@ -657,6 +660,19 @@ class LightningServiceTest : BaseUnitTest() { assertNull(sut.node) } + @Test + fun `stop releases a node with detached work when the app returns before the exit`() = test { + var backgroundChecks = 0 + sut = createService(isAppInBackground = { backgroundChecks++ == 0 }) + sut.runtimeMonitor.onLog(detachingRecord) + + sut.stop() + + verify(node).destroy() + assertEquals(0, exitCount) + assertEquals(2, backgroundChecks) + } + @Test fun `stop releases a node with detached work in foreground`() = test { sut.runtimeMonitor.onLog(detachingRecord) From 63eb5fabe2210d8987eeaa7dd2247534d87a40f2 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 10 Nov 2026 13:43:13 -0300 Subject: [PATCH 4/5] fix: detect background from started activities Co-Authored-By: Claude Opus 5.5 --- app/src/main/java/to/bitkit/App.kt | 20 +++++++- .../to/bitkit/services/LightningService.kt | 6 +-- .../java/to/bitkit/CurrentActivityTest.kt | 50 +++++++++++++++++++ 3 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 app/src/test/java/to/bitkit/CurrentActivityTest.kt diff --git a/app/src/main/java/to/bitkit/App.kt b/app/src/main/java/to/bitkit/App.kt index 0df1da560b..90b55cf8e9 100644 --- a/app/src/main/java/to/bitkit/App.kt +++ b/app/src/main/java/to/bitkit/App.kt @@ -23,6 +23,7 @@ import to.bitkit.services.PubkyAuthHandlerRegistrar import to.bitkit.utils.Crypto import to.bitkit.utils.Logger import to.bitkit.utils.SubscriptionClockOffsetSync +import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject @HiltAndroidApp @@ -99,11 +100,26 @@ class CurrentActivity : ActivityLifecycleCallbacks { var value: Activity? = null private set + private val startedCount = AtomicInteger(0) + + /** + * Whether any activity is started (visible), including one paused behind a system dialog. Use this, not + * `ProcessLifecycleOwner`, which never initializes here: the manifest removes androidx.startup's + * `InitializationProvider`. + */ + val hasStartedActivity: Boolean get() = startedCount.get() > 0 + override fun onActivityCreated(activity: Activity, bundle: Bundle?) = Unit - override fun onActivityStarted(activity: Activity) = run { this.value = activity } + override fun onActivityStarted(activity: Activity) = run { + startedCount.incrementAndGet() + this.value = activity + } override fun onActivityResumed(activity: Activity) = run { this.value = activity } override fun onActivityPaused(activity: Activity) = clearIfCurrent(activity) - override fun onActivityStopped(activity: Activity) = clearIfCurrent(activity) + override fun onActivityStopped(activity: Activity) = run { + startedCount.updateAndGet { maxOf(0, it - 1) } + clearIfCurrent(activity) + } override fun onActivitySaveInstanceState(activity: Activity, bundle: Bundle) = Unit override fun onActivityDestroyed(activity: Activity) = clearIfCurrent(activity) diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 6fe8f25e31..79365a92b5 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -1,8 +1,6 @@ package to.bitkit.services import android.os.Process -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.ProcessLifecycleOwner import com.synonym.bitkitcore.AddressType import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Job @@ -59,6 +57,7 @@ import org.lightningdevkit.ldknode.SpendableUtxo import org.lightningdevkit.ldknode.TransactionDetails import org.lightningdevkit.ldknode.Txid import org.lightningdevkit.ldknode.defaultConfig +import to.bitkit.App import to.bitkit.async.BaseCoroutineScope import to.bitkit.async.ServiceQueue import to.bitkit.data.SettingsStore @@ -1617,8 +1616,7 @@ internal fun exactOriginalSendFee( return inputTotal - outputTotal } -private fun isProcessInBackground(): Boolean = - !ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) +private fun isProcessInBackground(): Boolean = App.currentActivity?.hasStartedActivity != true /** * Flags a node whose stop timed out and left tracked tasks detached on its tokio runtime. diff --git a/app/src/test/java/to/bitkit/CurrentActivityTest.kt b/app/src/test/java/to/bitkit/CurrentActivityTest.kt new file mode 100644 index 0000000000..2c71e9cc69 --- /dev/null +++ b/app/src/test/java/to/bitkit/CurrentActivityTest.kt @@ -0,0 +1,50 @@ +package to.bitkit + +import android.app.Activity +import org.junit.Test +import org.mockito.kotlin.mock +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class CurrentActivityTest { + private val sut = CurrentActivity() + private val activity = mock() + + @Test + fun `has no started activity before any start`() { + assertFalse(sut.hasStartedActivity) + } + + @Test + fun `keeps a paused activity started while it stays visible`() { + sut.onActivityStarted(activity) + sut.onActivityResumed(activity) + + sut.onActivityPaused(activity) + + assertTrue(sut.hasStartedActivity) + assertNull(sut.value) + } + + @Test + fun `has no started activity once every started activity stops`() { + val other = mock() + sut.onActivityStarted(activity) + sut.onActivityStarted(other) + + sut.onActivityStopped(activity) + assertTrue(sut.hasStartedActivity) + + sut.onActivityStopped(other) + assertFalse(sut.hasStartedActivity) + } + + @Test + fun `ignores a stop without a matching start`() { + sut.onActivityStopped(activity) + sut.onActivityStarted(activity) + + assertTrue(sut.hasStartedActivity) + } +} From 7460ccfbe844064c13c8839acb01bb8b295d02de Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 10 Nov 2026 13:48:39 -0300 Subject: [PATCH 5/5] docs: narrow the changelog to background stops Co-Authored-By: Claude Opus 5.5 --- changelog.d/next/1536.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/next/1536.fixed.md b/changelog.d/next/1536.fixed.md index ebc23274a8..a13cdb715b 100644 --- a/changelog.d/next/1536.fixed.md +++ b/changelog.d/next/1536.fixed.md @@ -1 +1 @@ -Fix a rare crash when Bitkit returned from the background after the Lightning node was slow to stop. +Fix a rare crash when the Lightning node was slow to stop while Bitkit was in the background.