app

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

commit 1432018c9315751b20ff11b067521fcaa7d5264d
parent d89c3a846d0c5fb6bae906aaa596b3bac6453683
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 20:21:52 +0000

fix(runtime): harden desktop store lifecycle edges

- marshal native observer updates through the application scope
- ignore late callbacks and commands after explicit store closure
- clear consumed removal presentation state on confirmation failure
- verify post-close delivery and failed confirmation cleanup

Diffstat:
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt | 20+++++++++++++++++---
Mapp/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt | 54++++++++++++++++++++++++++++++++++++++++++++++++++----
2 files changed, 67 insertions(+), 7 deletions(-)

diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt @@ -28,7 +28,12 @@ class StudioAppStore( private val scope: CoroutineScope, ) : AutoCloseable { private val mutableState = mutableStateOf(StudioStoreState(snapshot = gateway.snapshot())) - private val subscription = gateway.subscribe(::acceptSnapshot) + private var closed = false + private val subscription = gateway.subscribe { snapshot -> + scope.launch { + if (!closed) acceptSnapshot(snapshot) + } + } private var pendingRemoval: RemovalTicket? = null private var command: Job? = null @@ -100,7 +105,13 @@ class StudioAppStore( launchCommand { runCatching { pendingRemoval?.close() - pendingRemoval = gateway.requestAccountRemoval(publicKeyHex) + pendingRemoval = null + val ticket = gateway.requestAccountRemoval(publicKeyHex) + if (closed) { + ticket.close() + return@runCatching + } + pendingRemoval = ticket mutableState.value = mutableState.value.copy( pendingRemovalPublicKeyHex = publicKeyHex, ) @@ -124,6 +135,7 @@ class StudioAppStore( } } finally { ticket.close() + mutableState.value = mutableState.value.copy(pendingRemovalPublicKeyHex = null) } } } @@ -137,7 +149,7 @@ class StudioAppStore( } private fun launchCommand(operation: suspend () -> Unit) { - if (command?.isActive == true) return + if (closed || command?.isActive == true) return mutableState.value = mutableState.value.copy(busy = true, problem = null) command = scope.launch { runCatching { operation() } @@ -159,6 +171,8 @@ class StudioAppStore( } override fun close() { + if (closed) return + closed = true command?.cancel() pendingRemoval?.close() subscription.close() diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt @@ -26,6 +26,7 @@ class StudioAppStoreTest { advanceUntilIdle() gateway.emit(snapshot(1UL)) gateway.emit(snapshot(0UL)) + advanceUntilIdle() assertEquals(1UL, store.state.value.snapshot.revision) assertFalse(store.state.value.busy) @@ -51,6 +52,40 @@ class StudioAppStoreTest { } @Test + fun `ignores observer delivery after close`() = runTest { + val gateway = FakeStudioCoreGateway(snapshot(0UL)) + val store = StudioAppStore(gateway, this) + advanceUntilIdle() + val revisionAtClose = store.state.value.snapshot.revision + + store.close() + gateway.emit(snapshot(revisionAtClose + 1UL)) + advanceUntilIdle() + + assertEquals(revisionAtClose, store.state.value.snapshot.revision) + } + + @Test + fun `failed removal confirmation clears consumed presentation state`() = runTest { + val gateway = FakeStudioCoreGateway(snapshot(0UL)).apply { + failRemovalConfirmation = true + } + val store = StudioAppStore(gateway, this) + advanceUntilIdle() + + store.requestAccountRemoval("00".repeat(32)) + advanceUntilIdle() + assertEquals("00".repeat(32), store.state.value.pendingRemovalPublicKeyHex) + store.confirmAccountRemoval() + advanceUntilIdle() + + assertNull(store.state.value.pendingRemovalPublicKeyHex) + assertTrue(gateway.lastRemovalTicket?.closed == true) + assertEquals("The application command failed.", store.state.value.problem) + store.close() + } + + @Test fun `serializes commands while one is active`() = runTest { val gateway = FakeStudioCoreGateway(snapshot(0UL)) val store = StudioAppStore(gateway, this) @@ -90,6 +125,8 @@ private class FakeStudioCoreGateway( var subscriptionClosed = false var signOutCalls = 0 val importedSecrets = mutableListOf<String>() + var failRemovalConfirmation = false + var lastRemovalTicket: FakeRemovalTicket? = null override fun snapshot(): AppSnapshotDto = current @@ -119,11 +156,20 @@ private class FakeStudioCoreGateway( override suspend fun signOut(): AppSnapshotDto = current.also { signOutCalls += 1 } override suspend fun refreshActiveProfile(): AppSnapshotDto = current override suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket = - object : RemovalTicket { - override fun close() = Unit - } + FakeRemovalTicket().also { lastRemovalTicket = it } - override suspend fun confirmAccountRemoval(ticket: RemovalTicket): AppSnapshotDto = current + override suspend fun confirmAccountRemoval(ticket: RemovalTicket): AppSnapshotDto { + if (failRemovalConfirmation) error("injected confirmation failure") + return current + } + + override fun close() { + closed = true + } +} + +private class FakeRemovalTicket : RemovalTicket { + var closed = false override fun close() { closed = true