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