app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

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:
Mapp/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleApplication.kt | 66+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mapp/desktop/src/test/kotlin/org/harvestcircle/application/HarvestCircleApplicationTest.kt | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/desktop/src/test/kotlin/org/harvestcircle/application/SecretClipboardControllerTest.kt | 10++++++++++
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt | 3+++
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt | 22++++++++++++++++++++++
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) }