app

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

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:
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt | 21+++++++++++++++++++--
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/StudioCoreGateway.kt | 23+++++++++++++++++++++--
Mapp/desktop/src/test/kotlin/org/radroots/studio/application/RadrootsApplicationTest.kt | 7++++++-
Mapp/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
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(),