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:
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)))