commit 23ddedd8f4988c202d3746b0eeeec7b70c2d98f0
parent 6c61b08a682f5230ceb006a5ae6750182be60cca
Author: triesap <tyson@radroots.org>
Date: Mon, 10 Aug 2026 17:52:10 +0000
lifecycle: close application scopes on disposal
- cancel composition-owned application work and clipboard ownership on disposal
- retain awaited shutdown while adding a bounded asynchronous native-close fallback
- make interrupted presenter shutdown retryable and repeated disposal idempotent
- cover normal close, abrupt disposal, cleanup completion, and clipboard replacement
Diffstat:
5 files changed, 171 insertions(+), 3 deletions(-)
diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleApplication.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleApplication.kt
@@ -1,16 +1,19 @@
package org.harvestcircle.application
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
+import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import org.harvestcircle.ffi.generateOperationIdV7
import org.harvestcircle.identities.ui.HarvestCirclePlatformActions
@@ -19,6 +22,7 @@ import org.harvestcircle.identities.ui.HarvestCircleUiActions
import org.harvestcircle.identities.ui.ShutdownFailureScreen
import org.harvestcircle.identities.ui.StartupFailureScreen
import org.harvestcircle.identities.ui.toUiModel
+import java.util.concurrent.atomic.AtomicBoolean
internal typealias HarvestCirclePresenterFactory = (CoroutineScope) -> HarvestCirclePresenter
@@ -32,6 +36,19 @@ fun HarvestCircleApplication(
val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Default) }
val presenterResult = remember { runCatching { presenterFactory(scope) } }
val presenter = presenterResult.getOrNull()
+ val clipboard = remember(presenter) { presenter?.let { SecretClipboardController(scope) } }
+ val lifecycle =
+ remember(scope, presenter, clipboard, shutdownTimeoutMillis) {
+ ApplicationLifecycleResources(
+ applicationScope = scope,
+ clipboard = clipboard,
+ closePresenter = presenter?.let { active -> suspend { active.close() } },
+ shutdownTimeoutMillis = shutdownTimeoutMillis,
+ )
+ }
+ DisposableEffect(lifecycle) {
+ onDispose { lifecycle.dispose() }
+ }
if (presenter == null) {
LaunchedEffect(closeRequested) {
if (closeRequested) onExitApproved()
@@ -42,16 +59,16 @@ fun HarvestCircleApplication(
StartupFailureScreen(message)
return
}
- val clipboard = remember { SecretClipboardController(scope) }
+ checkNotNull(clipboard)
val state by presenter.state.collectAsState()
var shutdownProblem by remember { mutableStateOf<String?>(null) }
LaunchedEffect(closeRequested, presenter) {
if (closeRequested) {
- clipboard.close()
+ lifecycle.closeClipboard()
val receipt = withTimeoutOrNull(shutdownTimeoutMillis) { presenter.close() }
if (receipt?.closed == true) {
- scope.cancel()
+ lifecycle.completeNormalClose()
onExitApproved()
} else {
shutdownProblem = "Native shutdown did not complete within the safe timeout."
@@ -95,6 +112,49 @@ fun HarvestCircleApplication(
internal const val DEFAULT_SHUTDOWN_TIMEOUT_MILLIS = 5_000L
+internal class ApplicationLifecycleResources(
+ private val applicationScope: CoroutineScope,
+ private val clipboard: AutoCloseable?,
+ private val closePresenter: (suspend () -> ShutdownReceipt?)?,
+ private val shutdownTimeoutMillis: Long,
+ private val fallbackDispatcher: CoroutineDispatcher = Dispatchers.Default,
+) {
+ private val clipboardClosed = AtomicBoolean(false)
+ private val normalCloseCompleted = AtomicBoolean(false)
+ private val disposed = AtomicBoolean(false)
+
+ init {
+ require(shutdownTimeoutMillis > 0L) { "Shutdown timeout must be positive" }
+ }
+
+ fun closeClipboard() {
+ if (clipboardClosed.compareAndSet(false, true)) clipboard?.close()
+ }
+
+ fun completeNormalClose() {
+ normalCloseCompleted.set(true)
+ closeClipboard()
+ applicationScope.cancel()
+ }
+
+ fun dispose() {
+ if (!disposed.compareAndSet(false, true)) return
+ closeClipboard()
+ applicationScope.cancel()
+ val close = closePresenter ?: return
+ if (normalCloseCompleted.get()) return
+
+ val fallbackJob = SupervisorJob()
+ CoroutineScope(fallbackJob + fallbackDispatcher).launch {
+ try {
+ withTimeoutOrNull(shutdownTimeoutMillis) { close() }
+ } finally {
+ fallbackJob.cancel()
+ }
+ }
+ }
+}
+
internal fun createHarvestCirclePresenter(scope: CoroutineScope): HarvestCirclePresenter {
val developmentMode = java.lang.Boolean.getBoolean("harvestcircle.development")
return HarvestCirclePresenter(
diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/application/HarvestCircleApplicationTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/application/HarvestCircleApplicationTest.kt
@@ -16,10 +16,14 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runComposeUiTest
import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertTrue
class HarvestCircleApplicationTest {
@OptIn(ExperimentalTestApi::class)
@@ -30,12 +34,14 @@ class HarvestCircleApplicationTest {
var factoryCalls = 0
var approvedExits = 0
var runtime: ApplicationRuntime? = null
+ var applicationJob: Job? = null
setContent {
HarvestCircleApplication(
closeRequested = closeRequested,
onExitApproved = { approvedExits += 1 },
) { scope ->
+ applicationJob = scope.coroutineContext[Job]
factoryCalls += 1
val createdRuntime = ApplicationRuntime()
runtime = createdRuntime
@@ -61,6 +67,71 @@ class HarvestCircleApplicationTest {
assertEquals(1, factoryCalls)
assertEquals(true, runtime?.closed)
+ assertEquals(1, runtime?.shutdownCalls)
+ assertTrue(applicationJob?.isCancelled == true)
+ }
+
+ @OptIn(ExperimentalTestApi::class)
+ @Test
+ fun abruptCompositionDisposalCancelsScopeAndFallsBackToNativeClose() =
+ runComposeUiTest {
+ var showApplication by mutableStateOf(true)
+ var runtime: ApplicationRuntime? = null
+ var applicationJob: Job? = null
+
+ setContent {
+ if (showApplication) {
+ HarvestCircleApplication { scope ->
+ applicationJob = scope.coroutineContext[Job]
+ val createdRuntime = ApplicationRuntime()
+ runtime = createdRuntime
+ HarvestCirclePresenter(
+ runtime = createdRuntime,
+ scope = scope,
+ clock = ApplicationClock { UnixSeconds(0) },
+ operationIds = OperationIdSource { OperationId.from(TEST_OPERATION_ID) },
+ )
+ }
+ }
+ BasicText(
+ text = "Dispose",
+ modifier = Modifier.testTag("dispose-application").clickable { showApplication = false },
+ )
+ }
+
+ onNodeWithTag("dispose-application").performClick()
+ waitUntil { runtime?.closed == true }
+
+ assertEquals(1, runtime?.shutdownCalls)
+ assertTrue(applicationJob?.isCancelled == true)
+ }
+
+ @Test
+ fun repeatedDisposalIsIdempotentAndDoesNotLeaveCleanupWork() =
+ runTest {
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ val applicationJob = Job()
+ var clipboardCloses = 0
+ var presenterCloses = 0
+ val lifecycle =
+ ApplicationLifecycleResources(
+ applicationScope = kotlinx.coroutines.CoroutineScope(applicationJob + dispatcher),
+ clipboard = AutoCloseable { clipboardCloses += 1 },
+ closePresenter = {
+ presenterCloses += 1
+ ShutdownReceipt(SnapshotRevision(1UL), closed = true)
+ },
+ shutdownTimeoutMillis = 5_000,
+ fallbackDispatcher = dispatcher,
+ )
+
+ lifecycle.dispose()
+ lifecycle.dispose()
+ testScheduler.advanceUntilIdle()
+
+ assertTrue(applicationJob.isCancelled)
+ assertEquals(1, clipboardCloses)
+ assertEquals(1, presenterCloses)
}
@OptIn(ExperimentalTestApi::class)
@@ -137,6 +208,7 @@ private class ApplicationRuntime(
override val buildInfo: BuildInfo = BuildInfo.unknown()
var closed = false
+ var shutdownCalls = 0
override suspend fun bootstrap(): ApplicationSnapshot = applicationSnapshot(SnapshotRevision(1UL))
@@ -153,6 +225,7 @@ private class ApplicationRuntime(
override suspend fun cancelIdentityRemoval(requestId: RemovalRequestId): Boolean = false
override suspend fun shutdown(): ShutdownReceipt {
+ shutdownCalls += 1
shutdownGate?.await()
closed = true
return ShutdownReceipt(SnapshotRevision(1UL), closed = shutdownClosed)
diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/application/SecretClipboardControllerTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/application/SecretClipboardControllerTest.kt
@@ -74,6 +74,16 @@ class SecretClipboardControllerTest {
replacedClipboard.writeText("replacement")
replacedController.close()
assertEquals("replacement", replacedClipboard.value)
+
+ val sharedClipboard = FakeTextClipboard()
+ val firstOwner = SecretClipboardController(this, sharedClipboard)
+ val replacementOwner = SecretClipboardController(this, sharedClipboard)
+ firstOwner.copy("nsec1first")
+ replacementOwner.copy("nsec1second")
+ firstOwner.close()
+ assertEquals("nsec1second", sharedClipboard.value)
+ replacementOwner.close()
+ assertEquals("", sharedClipboard.value)
}
@Test
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt
@@ -100,6 +100,9 @@ class HarvestCirclePresenter(
)
}
}
+ } catch (error: CancellationException) {
+ closed = false
+ throw error
} catch (error: Exception) {
acceptFailure(error, null)
updateState { copy(route = HarvestCircleRoute.FATAL, busy = false) }
diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt
@@ -175,6 +175,26 @@ class HarvestCirclePresenterTest {
}
@Test
+ fun cancelledCloseCanBeRetriedByDisposalFallback() =
+ runTest {
+ val shutdownGate = CompletableDeferred<Unit>()
+ val runtime = FakePresenterRuntime(shutdownGate = shutdownGate)
+ val presenter = presenter(runtime)
+ runCurrent()
+
+ val interrupted = async { presenter.close() }
+ runCurrent()
+ interrupted.cancel()
+ runCurrent()
+ shutdownGate.complete(Unit)
+
+ val receipt = presenter.close()
+
+ assertTrue(receipt?.closed == true)
+ assertEquals(2, runtime.shutdownCalls)
+ }
+
+ @Test
fun generatedRecoveryIsOneUseAndClearedOnCancellation() =
runTest {
val runtime = FakePresenterRuntime()
@@ -256,6 +276,7 @@ private class DeterministicOperationIds : OperationIdSource {
private class FakePresenterRuntime(
private val bootstrapGate: CompletableDeferred<Unit>? = null,
private val executeGate: CompletableDeferred<Unit>? = null,
+ private val shutdownGate: CompletableDeferred<Unit>? = null,
) : HarvestCircleRuntime {
override val buildInfo: BuildInfo = BuildInfo.unknown()
@@ -328,6 +349,7 @@ private class FakePresenterRuntime(
override suspend fun shutdown(): ShutdownReceipt {
shutdownCalled = true
shutdownCalls += 1
+ shutdownGate?.await()
return ShutdownReceipt(current.revision, closed = true)
}