commit b051762edc891986f437dba9252e0dd38c5d8b69
parent 8ec6d269209aabd864469d12a3d4e8159959ff66
Author: triesap <tyson@radroots.org>
Date: Mon, 3 Aug 2026 23:50:06 +0000
application: await native runtime shutdown
- expose an idempotent typed shutdown receipt through the gateway
- reject mutation outside ready application routes
- fail closed when native disposal cannot confirm closure
- cover boot fatal saturation disposal and closed behavior
Diffstat:
4 files changed, 103 insertions(+), 9 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
@@ -256,6 +256,13 @@ class StudioAppStore(
)
return true
}
+ if (mutableState.value.route !in setOf(StudioRoute.ACCOUNTS, StudioRoute.ACTIVE_ACCOUNT)) {
+ mutableState.value = mutableState.value.copy(
+ commandStatus = CommandStatus.FAILED_TERMINAL,
+ problem = "The application runtime is not ready for this action.",
+ )
+ return true
+ }
return false
}
@@ -297,8 +304,18 @@ class StudioAppStore(
pendingGeneratedRecovery?.close()
subscription?.close()
generatedRecovery.close()
- gateway.close()
- mutableState.value = mutableState.value.copy(route = StudioRoute.CLOSED, busy = false)
+ runCatching { gateway.shutdown() }
+ .onSuccess { receipt ->
+ mutableState.value = mutableState.value.copy(
+ route = if (receipt.closed) StudioRoute.CLOSED else StudioRoute.FATAL,
+ busy = false,
+ problem = if (receipt.closed) null else "The application could not shut down safely.",
+ )
+ }
+ .onFailure { error ->
+ acceptFailure(error)
+ mutableState.value = mutableState.value.copy(route = StudioRoute.FATAL, busy = false)
+ }
}
}
diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioCoreGateway.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioCoreGateway.kt
@@ -56,6 +56,11 @@ data class StudioCommandFailure(
val safeMessage: String,
)
+data class StudioShutdownReceipt(
+ val finalRevision: ULong,
+ val closed: Boolean,
+)
+
sealed interface StudioCommandResult {
data class Accepted(val receipt: StudioCommandReceipt) : StudioCommandResult
data class Rejected(val failure: StudioCommandFailure) : StudioCommandResult
@@ -75,12 +80,16 @@ interface StudioCoreGateway : AutoCloseable {
suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket
suspend fun confirmAccountRemoval(ticket: RemovalTicket): AppSnapshotDto
+
+ fun shutdown(): StudioShutdownReceipt
}
class NativeStudioCoreGateway(
private val core: StudioAppCore,
) : StudioCoreGateway {
private val nextRequest = AtomicLong(1)
+ private val shutdownLock = Any()
+ private var shutdownReceipt: StudioShutdownReceipt? = null
override fun snapshot(): AppSnapshotDto = core.snapshot()
override suspend fun subscribeChanges(onChange: (StudioChange) -> Unit): AutoCloseable {
@@ -129,9 +138,19 @@ class NativeStudioCoreGateway(
return core.confirmAccountRemoval(ticket.request)
}
+ override fun shutdown(): StudioShutdownReceipt = synchronized(shutdownLock) {
+ shutdownReceipt ?: run {
+ val receipt = runBlocking { core.shutdownV2() }
+ StudioShutdownReceipt(receipt.finalRevision, receipt.closed).also {
+ check(it.closed) { "Native runtime returned an incomplete shutdown receipt" }
+ shutdownReceipt = it
+ core.close()
+ }
+ }
+ }
+
override fun close() {
- runBlocking { core.shutdownV2() }
- core.close()
+ shutdown()
}
private fun requestContext(): RequestContextDto = RequestContextDto(
diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/application/RadrootsApplicationTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/RadrootsApplicationTest.kt
@@ -82,8 +82,13 @@ private class ApplicationGateway : StudioCoreGateway {
override suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket = error("unused")
override suspend fun confirmAccountRemoval(ticket: RemovalTicket) = error("unused")
- override fun close() {
+ override fun shutdown(): StudioShutdownReceipt {
closed = true
+ return StudioShutdownReceipt(1UL, closed = true)
+ }
+
+ override fun close() {
+ shutdown()
}
}
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
@@ -34,7 +34,9 @@ class StudioAppStoreTest {
assertFalse(store.state.value.busy)
store.close()
assertTrue(gateway.closed)
+ assertTrue(gateway.shutdownCompleted)
assertTrue(gateway.subscriptionClosed)
+ assertEquals(StudioRoute.CLOSED, store.state.value.route)
}
@Test
@@ -145,13 +147,55 @@ class StudioAppStoreTest {
assertEquals(true, gateway.lastImportBuffer?.all { it == 0.toByte() })
store.close()
}
+
+ @Test
+ fun `projects boot fatal and terminal lifecycle failures`() = runTest {
+ val booting = snapshot(0UL, AppLifecycleDto.BOOTING)
+ val bootGateway = FakeStudioCoreGateway(booting, booting)
+ val bootStore = StudioAppStore(bootGateway, this)
+ advanceUntilIdle()
+ assertEquals(StudioRoute.BOOTING, bootStore.state.value.route)
+ bootStore.close()
+
+ val fatal = snapshot(1UL, AppLifecycleDto.FATAL)
+ val gateway = FakeStudioCoreGateway(fatal, fatal)
+ val store = StudioAppStore(gateway, this)
+ advanceUntilIdle()
+ assertEquals(StudioRoute.FATAL, store.state.value.route)
+ store.signOut()
+ advanceUntilIdle()
+ assertEquals(CommandStatus.FAILED_TERMINAL, store.state.value.commandStatus)
+ assertEquals(0, gateway.signOutCalls)
+
+ store.close()
+ store.signOut()
+ assertEquals(CommandStatus.REJECTED_CLOSED, store.state.value.commandStatus)
+ }
+
+ @Test
+ fun `disposal waits for native shutdown and fails closed on an incomplete receipt`() = runTest {
+ val gateway = FakeStudioCoreGateway(snapshot(0UL)).apply {
+ shutdownReceipt = StudioShutdownReceipt(1UL, closed = false)
+ }
+ val store = StudioAppStore(gateway, this)
+ advanceUntilIdle()
+
+ store.close()
+
+ assertTrue(gateway.shutdownCompleted)
+ assertEquals(StudioRoute.FATAL, store.state.value.route)
+ assertEquals("The application could not shut down safely.", store.state.value.problem)
+ }
}
private class FakeStudioCoreGateway(
private var current: AppSnapshotDto,
+ private val bootstrapSnapshot: AppSnapshotDto = snapshot(1UL),
) : StudioCoreGateway {
private var observer: ((AppSnapshotDto) -> Unit)? = null
var closed = false
+ var shutdownCompleted = false
+ var shutdownReceipt = StudioShutdownReceipt(current.revision, closed = true)
var subscriptionClosed = false
var signOutCalls = 0
val importedSecrets = mutableListOf<String>()
@@ -191,7 +235,7 @@ private class FakeStudioCoreGateway(
observer?.invoke(snapshot)
}
- override suspend fun bootstrap(): AppSnapshotDto = snapshot(1UL).also(::emit)
+ override suspend fun bootstrap(): AppSnapshotDto = bootstrapSnapshot.also(::emit)
override suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket =
FakeGeneratedRecoveryTicket(account()) { committed ->
@@ -208,8 +252,14 @@ private class FakeStudioCoreGateway(
return current
}
- override fun close() {
+ override fun shutdown(): StudioShutdownReceipt {
+ shutdownCompleted = true
closed = true
+ return shutdownReceipt
+ }
+
+ override fun close() {
+ shutdown()
}
}
@@ -241,9 +291,12 @@ private class FakeRemovalTicket : RemovalTicket {
}
}
-private fun snapshot(revision: ULong) = AppSnapshotDto(
+private fun snapshot(
+ revision: ULong,
+ lifecycle: AppLifecycleDto = AppLifecycleDto.READY,
+) = AppSnapshotDto(
revision = revision,
- lifecycle = AppLifecycleDto.READY,
+ lifecycle = lifecycle,
lifecycleError = null,
configuredRelays = emptyList(),
accounts = emptyList(),