app

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

commit ec6a68eb76c7df3417c03d770f50621391d0db07
parent 95e74b76287369d1cd507d049272913dcabec766
Author: triesap <tyson@radroots.org>
Date:   Mon, 10 Aug 2026 02:54:17 +0000

lifecycle: make native disposal asynchronous

- await subscription teardown and remove application-path runBlocking
- make presenter and native shutdown receipts idempotent across repeated close
- keep the desktop window alive until ordered shutdown completes successfully
- surface bounded timeout or native failure behind an explicit force-exit choice

Diffstat:
Mapp/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleApplication.kt | 36+++++++++++++++++++++++++++++-------
Mapp/desktop/src/main/kotlin/org/harvestcircle/application/NativeHarvestCircleRuntime.kt | 23++++++++++++++---------
Mapp/desktop/src/main/kotlin/org/harvestcircle/desktop/Main.kt | 14++++++++++++--
Mapp/desktop/src/test/kotlin/org/harvestcircle/application/HarvestCircleApplicationTest.kt | 100++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt | 49++++++++++++++++++++++++++++---------------------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/identities/ui/HarvestCircleScreen.kt | 26++++++++++++++++++++++++++
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt | 7++++++-
Mcore/crates/harvestcircle_ffi/src/observer.rs | 10+++++++---
8 files changed, 201 insertions(+), 64 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,18 +1,21 @@ 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.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import org.harvestcircle.identities.ui.HarvestCirclePlatformActions import org.harvestcircle.identities.ui.HarvestCircleScreen 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.AtomicLong @@ -20,11 +23,19 @@ import java.util.concurrent.atomic.AtomicLong internal typealias HarvestCirclePresenterFactory = (CoroutineScope) -> HarvestCirclePresenter @Composable -fun HarvestCircleApplication(presenterFactory: HarvestCirclePresenterFactory = ::createHarvestCirclePresenter) { +fun HarvestCircleApplication( + closeRequested: Boolean = false, + onExitApproved: () -> Unit = {}, + shutdownTimeoutMillis: Long = DEFAULT_SHUTDOWN_TIMEOUT_MILLIS, + presenterFactory: HarvestCirclePresenterFactory = ::createHarvestCirclePresenter, +) { val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Default) } val presenterResult = remember { runCatching { presenterFactory(scope) } } val presenter = presenterResult.getOrNull() if (presenter == null) { + LaunchedEffect(closeRequested) { + if (closeRequested) onExitApproved() + } val message = (presenterResult.exceptionOrNull() as? ApplicationFailure)?.problem?.safeMessage ?: "The application could not start." @@ -33,17 +44,26 @@ fun HarvestCircleApplication(presenterFactory: HarvestCirclePresenterFactory = : } val clipboard = remember { SecretClipboardController(scope) } val state by presenter.state.collectAsState() + var shutdownProblem by remember { mutableStateOf<String?>(null) } - DisposableEffect(presenter, clipboard) { - onDispose { + LaunchedEffect(closeRequested, presenter) { + if (closeRequested) { clipboard.close() - scope.launch { - presenter.close() + val receipt = withTimeoutOrNull(shutdownTimeoutMillis) { presenter.close() } + if (receipt?.closed == true) { scope.cancel() + onExitApproved() + } else { + shutdownProblem = "Native shutdown did not complete within the safe timeout." } } } + shutdownProblem?.let { problem -> + ShutdownFailureScreen(problem = problem, forceExit = onExitApproved) + return + } + HarvestCircleScreen( model = state.toUiModel(), actions = @@ -73,6 +93,8 @@ fun HarvestCircleApplication(presenterFactory: HarvestCirclePresenterFactory = : ) } +internal const val DEFAULT_SHUTDOWN_TIMEOUT_MILLIS = 5_000L + internal fun createHarvestCirclePresenter(scope: CoroutineScope): HarvestCirclePresenter { val developmentMode = java.lang.Boolean.getBoolean("harvestcircle.development") return HarvestCirclePresenter( diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeHarvestCircleRuntime.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeHarvestCircleRuntime.kt @@ -1,12 +1,13 @@ package org.harvestcircle.application import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import org.harvestcircle.ffi.AppSnapshotDto import org.harvestcircle.ffi.GeneratedRecoveryRequest import org.harvestcircle.ffi.HarvestCircleAppCore @@ -44,14 +45,18 @@ class NativeHarvestCircleRuntime internal constructor( } override fun changes(): Flow<ApplicationChange> = - callbackFlow { + channelFlow { val subscription = callNative { native.subscribe { change -> trySend(change.toApplicationChange()) } } - awaitClose(subscription::close) + try { + awaitCancellation() + } finally { + withContext(NonCancellable) { subscription.unsubscribe() } + } } override suspend fun execute(command: ApplicationCommand): ApplicationCommandResult = @@ -318,8 +323,8 @@ internal interface NativeRemovalHandle : AutoCloseable { fun expiresAtSeconds(): Long } -internal fun interface NativeSubscriptionHandle : AutoCloseable { - override fun close() +internal fun interface NativeSubscriptionHandle { + suspend fun unsubscribe() } private class UniFfiNativeCorePort( @@ -413,9 +418,9 @@ private class UniFfiRemovalHandle( private class UniFfiNativeSubscriptionHandle( private val subscription: ObserverSubscription, ) : NativeSubscriptionHandle { - override fun close() { + override suspend fun unsubscribe() { try { - runBlocking { subscription.unsubscribe() } + subscription.unsubscribe() } finally { subscription.close() } diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/desktop/Main.kt b/app/desktop/src/main/kotlin/org/harvestcircle/desktop/Main.kt @@ -1,6 +1,10 @@ package org.harvestcircle.desktop import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window import androidx.compose.ui.window.application @@ -26,8 +30,11 @@ fun main() { val nativeStartupProblem = if (isMacOs) configureMacOsApplication() else null application { + var closeRequested by remember { mutableStateOf(false) } Window( - onCloseRequest = ::exitApplication, + onCloseRequest = { + if (nativeStartupProblem == null) closeRequested = true else exitApplication() + }, title = APPLICATION_NAME, state = rememberWindowState( @@ -49,7 +56,10 @@ fun main() { } if (nativeStartupProblem == null) { - HarvestCircleApplication() + HarvestCircleApplication( + closeRequested = closeRequested, + onExitApproved = ::exitApplication, + ) } else { StartupFailureScreen(nativeStartupProblem) } diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/application/HarvestCircleApplicationTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/application/HarvestCircleApplicationTest.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.test.onNodeWithTag 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.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlin.test.Test @@ -23,38 +24,40 @@ import kotlin.test.assertEquals class HarvestCircleApplicationTest { @OptIn(ExperimentalTestApi::class) @Test - fun applicationCreatesOnePresenterAcrossRecompositionAndClosesItOnDisposal() = + fun applicationCreatesOnePresenterAndAwaitsCloseBeforeApprovingExit() = runComposeUiTest { - var applicationVisible by mutableStateOf(true) + var closeRequested by mutableStateOf(false) var factoryCalls = 0 + var approvedExits = 0 var runtime: ApplicationRuntime? = null setContent { - if (applicationVisible) { - HarvestCircleApplication { scope -> - factoryCalls += 1 - val createdRuntime = ApplicationRuntime() - runtime = createdRuntime - HarvestCirclePresenter( - runtime = createdRuntime, - scope = scope, - clock = ApplicationClock { UnixSeconds(0) }, - operationIds = OperationIdSource { OperationId.from("application-test") }, - ) - } + HarvestCircleApplication( + closeRequested = closeRequested, + onExitApproved = { approvedExits += 1 }, + ) { scope -> + factoryCalls += 1 + val createdRuntime = ApplicationRuntime() + runtime = createdRuntime + HarvestCirclePresenter( + runtime = createdRuntime, + scope = scope, + clock = ApplicationClock { UnixSeconds(0) }, + operationIds = OperationIdSource { OperationId.from("application-test") }, + ) } BasicText( - text = "Toggle", + text = "Close", modifier = Modifier - .testTag("toggle-application") - .clickable { applicationVisible = !applicationVisible }, + .testTag("close-application") + .clickable { closeRequested = true }, ) } onNodeWithText("HarvestCircle").assertIsDisplayed() - onNodeWithTag("toggle-application").performClick() - waitUntil { runtime?.closed == true } + onNodeWithTag("close-application").performClick() + waitUntil { runtime?.closed == true && approvedExits == 1 } assertEquals(1, factoryCalls) assertEquals(true, runtime?.closed) @@ -62,6 +65,57 @@ class HarvestCircleApplicationTest { @OptIn(ExperimentalTestApi::class) @Test + fun failedNativeShutdownRequiresAnExplicitForceExitChoice() = + runComposeUiTest { + var approvedExits = 0 + setContent { + HarvestCircleApplication( + closeRequested = true, + onExitApproved = { approvedExits += 1 }, + ) { scope -> + HarvestCirclePresenter( + runtime = ApplicationRuntime(shutdownClosed = false), + scope = scope, + clock = ApplicationClock { UnixSeconds(0) }, + operationIds = OperationIdSource { OperationId.from("application-test") }, + ) + } + } + + onNodeWithTag("shutdown-failure").assertIsDisplayed() + assertEquals(0, approvedExits) + onNodeWithTag("force-exit").performClick() + assertEquals(1, approvedExits) + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun shutdownTimeoutRequiresAnExplicitForceExitChoice() = + runComposeUiTest { + var approvedExits = 0 + setContent { + HarvestCircleApplication( + closeRequested = true, + onExitApproved = { approvedExits += 1 }, + shutdownTimeoutMillis = 1, + ) { scope -> + HarvestCirclePresenter( + runtime = ApplicationRuntime(shutdownGate = CompletableDeferred()), + scope = scope, + clock = ApplicationClock { UnixSeconds(0) }, + operationIds = OperationIdSource { OperationId.from("application-test") }, + ) + } + } + + onNodeWithTag("shutdown-failure").assertIsDisplayed() + assertEquals(0, approvedExits) + onNodeWithTag("force-exit").performClick() + assertEquals(1, approvedExits) + } + + @OptIn(ExperimentalTestApi::class) + @Test fun applicationRendersSafeStartupFailureWithoutLeakingInternalMessage() = runComposeUiTest { setContent { @@ -76,7 +130,10 @@ class HarvestCircleApplicationTest { } } -private class ApplicationRuntime : HarvestCircleRuntime { +private class ApplicationRuntime( + private val shutdownClosed: Boolean = true, + private val shutdownGate: CompletableDeferred<Unit>? = null, +) : HarvestCircleRuntime { var closed = false override suspend fun bootstrap(): ApplicationSnapshot = applicationSnapshot(SnapshotRevision(1UL)) @@ -94,8 +151,9 @@ private class ApplicationRuntime : HarvestCircleRuntime { override suspend fun cancelIdentityRemoval(requestId: RemovalRequestId): Boolean = false override suspend fun shutdown(): ShutdownReceipt { + shutdownGate?.await() closed = true - return ShutdownReceipt(SnapshotRevision(1UL), closed = true) + return ShutdownReceipt(SnapshotRevision(1UL), closed = shutdownClosed) } } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt @@ -13,6 +13,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock class HarvestCirclePresenter( private val runtime: HarvestCircleRuntime, @@ -27,6 +29,8 @@ class HarvestCirclePresenter( private var pendingRecovery: GeneratedIdentityRecovery? = null private var pendingRemoval: IdentityRemovalRequest? = null private var pendingRetry: PendingRetry? = null + private val closeMutex = Mutex() + private var closeReceipt: ShutdownReceipt? = null private var closed = false val state: StateFlow<HarvestCirclePresenterState> = mutableState.asStateFlow() @@ -75,30 +79,33 @@ class HarvestCirclePresenter( } } - suspend fun close(): ShutdownReceipt? { - if (closed) return null - closed = true - updateState { copy(route = HarvestCircleRoute.SHUTTING_DOWN, busy = true, problem = null) } - commandJob?.cancelAndJoin() - subscriptionJob?.cancelAndJoin() - releaseRecovery() - pendingRemoval = null - return try { - runtime.shutdown().also { receipt -> - updateState { - copy( - route = if (receipt.closed) HarvestCircleRoute.CLOSED else HarvestCircleRoute.FATAL, - busy = false, - problem = if (receipt.closed) null else "The application could not shut down safely.", - ) + suspend fun close(): ShutdownReceipt? = + closeMutex.withLock { + closeReceipt?.let { return@withLock it } + if (closed) return@withLock null + closed = true + updateState { copy(route = HarvestCircleRoute.SHUTTING_DOWN, busy = true, problem = null) } + commandJob?.cancelAndJoin() + subscriptionJob?.cancelAndJoin() + releaseRecovery() + pendingRemoval = null + return try { + runtime.shutdown().also { receipt -> + closeReceipt = receipt + updateState { + copy( + route = if (receipt.closed) HarvestCircleRoute.CLOSED else HarvestCircleRoute.FATAL, + busy = false, + problem = if (receipt.closed) null else "The application could not shut down safely.", + ) + } } + } catch (error: Exception) { + acceptFailure(error, null) + updateState { copy(route = HarvestCircleRoute.FATAL, busy = false) } + null } - } catch (error: Exception) { - acceptFailure(error, null) - updateState { copy(route = HarvestCircleRoute.FATAL, busy = false) } - null } - } private fun editImportDraft(value: String) { updateState { diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/identities/ui/HarvestCircleScreen.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/identities/ui/HarvestCircleScreen.kt @@ -81,6 +81,32 @@ fun StartupFailureScreen(problem: String) { } @Composable +fun ShutdownFailureScreen( + problem: String, + forceExit: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxSize() + .background(WindowBackgroundColor) + .padding(24.dp) + .verticalScroll(rememberScrollState()) + .testTag("shutdown-failure"), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + BasicText("HarvestCircle could not close safely") + BasicText(problem, Modifier.testTag("shutdown-problem")) + TextAction( + text = "Force exit", + testTag = "force-exit", + contentDescription = "Force HarvestCircle to exit", + onClick = forceExit, + ) + } +} + +@Composable fun HarvestCircleScreen( model: HarvestCircleUiModel, actions: HarvestCircleUiActions, diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt @@ -111,7 +111,10 @@ class HarvestCirclePresenterTest { assertTrue(runtime.executeCancelled) assertTrue(runtime.shutdownCalled) assertEquals(HarvestCircleRoute.CLOSED, presenter.state.value.route) - assertTrue(receipt.await()?.closed == true) + val first = receipt.await() + assertTrue(first?.closed == true) + assertEquals(first, presenter.close()) + assertEquals(1, runtime.shutdownCalls) } @Test @@ -203,6 +206,7 @@ private class FakePresenterRuntime( var executeCalls = 0 var executeCancelled = false var shutdownCalled = false + var shutdownCalls = 0 var generatedCancellationCalls = 0 var removalCancellationCalls = 0 val importedSecrets = mutableListOf<String>() @@ -260,6 +264,7 @@ private class FakePresenterRuntime( override suspend fun shutdown(): ShutdownReceipt { shutdownCalled = true + shutdownCalls += 1 return ShutdownReceipt(current.revision, closed = true) } diff --git a/core/crates/harvestcircle_ffi/src/observer.rs b/core/crates/harvestcircle_ffi/src/observer.rs @@ -163,7 +163,10 @@ impl HarvestCircleAppCore { /// Returns a safe closed or timeout error when shutdown cannot complete. pub async fn shutdown_v2(&self) -> Result<ShutdownReceiptDto, HarvestCircleError> { if self.inner.closed.swap(true, Ordering::AcqRel) { - return Err(closed_error()); + return Ok(ShutdownReceiptDto { + final_revision: self.inner.actor.snapshot().revision().value(), + closed: true, + }); } let handles = std::mem::take( &mut *self @@ -342,8 +345,9 @@ mod tests { .expect("observer task"); handle.abort(); - core.shutdown_v2().await.expect("shutdown"); - assert!(core.shutdown_v2().await.is_err()); + let first = core.shutdown_v2().await.expect("shutdown"); + let repeated = core.shutdown_v2().await.expect("repeated shutdown"); + assert_eq!(repeated, first); assert!( core.subscribe_changes_v2(Box::new(ArcObserver(observer)))