app

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

commit 53c8d7f48977e2317d15c02957f23402ed121f75
parent 7fab7ac8cc6251942d6ab1c7dde7cb73a393fee7
Author: triesap <tyson@radroots.org>
Date:   Thu, 13 Aug 2026 05:19:48 +0000

identity: expire removal leases automatically

- schedule one structured timer for each open removal lease
- serialize confirm cancel replacement expiry and shutdown ownership
- transfer residual handles through retryable native shutdown
- prove virtual deadlines and exact active-handle cleanup

Diffstat:
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt | 389++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mtools/xtask/src/lib.rs | 9+++++++--
3 files changed, 406 insertions(+), 95 deletions(-)

diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -31,9 +32,12 @@ class HarvestCirclePresenter( private var pendingRecovery: GeneratedIdentityRecovery? = null private var pendingRemoval: PendingRemovalLease? = null private var pendingRetry: PendingRetry? = null + private val removalMutex = Mutex() private val closeMutex = Mutex() private var closeReceipt: ShutdownReceipt? = null - private var closed = false + private var closePhase = PresenterClosePhase.Open + private val closed: Boolean + get() = closePhase != PresenterClosePhase.Open override val state: StateFlow<HarvestCirclePresenterState> = mutableState.asStateFlow() val effects: SharedFlow<HarvestCircleEffect> = mutableEffects.asSharedFlow() @@ -88,17 +92,24 @@ class HarvestCirclePresenter( 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() - clearImportDraft() - pendingRemoval = null + if (closePhase == PresenterClosePhase.Open) { + closePhase = PresenterClosePhase.Closing + updateState { copy(route = HarvestCircleRoute.SHUTTING_DOWN, busy = true, problem = null) } + } + if (closePhase == PresenterClosePhase.Closing) { + commandJob?.cancelAndJoin() + subscriptionJob?.cancelAndJoin() + releaseRecovery() + clearImportDraft() + transferRemovalToShutdown() + closePhase = PresenterClosePhase.TransferredToShutdown + } return try { runtime.shutdown().also { receipt -> - closeReceipt = receipt + if (receipt.closed) { + closeReceipt = receipt + closePhase = PresenterClosePhase.Closed + } updateState { copy( route = if (receipt.closed) HarvestCircleRoute.CLOSED else HarvestCircleRoute.FATAL, @@ -108,7 +119,6 @@ class HarvestCirclePresenter( } } } catch (error: CancellationException) { - closed = false throw error } catch (error: Exception) { acceptFailure(error, null) @@ -223,7 +233,7 @@ class HarvestCirclePresenter( private fun requestIdentityRemoval(intent: HarvestCircleIntent.RequestIdentityRemoval) { launchOperation { - pendingRemoval?.let { previous -> releaseRemovalRequest(previous, RemovalStatus.NONE) } + releaseCurrentRemovalForReplacement() updateState { copy( removalConfirmation = null, @@ -232,9 +242,18 @@ class HarvestCirclePresenter( } val request = runtime.requestIdentityRemoval(intent.identityId) val lease = PendingRemovalLease(request) - pendingRemoval = lease - if (request.isExpired()) { - releaseRemovalRequest(lease, RemovalStatus.FAILED) + val expired = request.isExpired() + removalMutex.withLock { + pendingRemoval = lease + if (expired) { + lease.phase = RemovalLeasePhase.Releasing + lease.releaseReason = RemovalReleaseReason.Expired + } else { + lease.expiryJob = scope.launch { expireRemovalLease(lease) } + } + } + if (expired) { + releaseClaimedRemoval(ClaimedRemovalRelease(lease, RemovalReleaseReason.Expired), RemovalStatus.FAILED) throw ApplicationFailure(removalExpiredProblem()) } updateState { @@ -247,36 +266,38 @@ class HarvestCirclePresenter( } private fun cancelIdentityRemoval(intent: HarvestCircleIntent.CancelIdentityRemoval) { - val lease = matchingRemoval(intent.identityId, intent.requestId) ?: return launchOperation { - val expired = lease.request.isExpired() - releaseRemovalRequest(lease, if (expired) RemovalStatus.FAILED else RemovalStatus.NONE) - if (expired) { + val claim = + claimMatchingRemoval(intent.identityId, intent.requestId, RemovalTerminalAction.Cancel) + ?: return@launchOperation + releaseClaimedRemoval( + ClaimedRemovalRelease(claim.lease, claim.releaseReason ?: RemovalReleaseReason.UserCancelled), + if (claim.expired) RemovalStatus.FAILED else RemovalStatus.NONE, + ) + if (claim.expired) { throw ApplicationFailure(removalExpiredProblem()) } } } private fun confirmIdentityRemoval(intent: HarvestCircleIntent.ConfirmIdentityRemoval) { - val lease = matchingRemoval(intent.identityId, intent.requestId) ?: return - val request = lease.request val operationId = operationIds.next() - launchOperation( - operationId = operationId, - onAccepted = { - lease.phase = RemovalLeasePhase.Confirming - updateState { - copy( - removalConfirmation = null, - removalStatus = RemovalStatus.CONFIRMING, - ) - } - }, - ) { - if (request.isExpired()) { - releaseRemovalRequest(lease, RemovalStatus.FAILED) + launchOperation(operationId = operationId) { + val claim = + claimMatchingRemoval(intent.identityId, intent.requestId, RemovalTerminalAction.Confirm) + ?: return@launchOperation + val lease = claim.lease + val request = lease.request + if (claim.expired) { + releaseClaimedRemoval(ClaimedRemovalRelease(lease, RemovalReleaseReason.Expired), RemovalStatus.FAILED) throw ApplicationFailure(removalExpiredProblem(operationId)) } + updateState { + copy( + removalConfirmation = null, + removalStatus = RemovalStatus.CONFIRMING, + ) + } try { val result = runtime.execute( @@ -286,8 +307,12 @@ class HarvestCirclePresenter( ), ) acceptResult(result, operationId) - lease.phase = RemovalLeasePhase.Confirmed - if (pendingRemoval === lease) pendingRemoval = null + removalMutex.withLock { + if (pendingRemoval === lease && lease.phase == RemovalLeasePhase.Confirming) { + lease.phase = RemovalLeasePhase.Confirmed + pendingRemoval = null + } + } updateState { copy( removalConfirmation = null, @@ -298,71 +323,149 @@ class HarvestCirclePresenter( mutableEffects.tryEmit(HarvestCircleEffect.IdentityRemoved(request.identityId)) } catch (error: CancellationException) { withContext(NonCancellable) { - releaseRemovalRequest(lease, RemovalStatus.FAILED) + releaseAfterFailedConfirmation(lease) } throw error } catch (error: Exception) { - releaseRemovalRequest(lease, RemovalStatus.FAILED) + releaseAfterFailedConfirmation(lease) throw error } } } - private fun matchingRemoval( + private suspend fun claimMatchingRemoval( identityId: IdentityId, requestId: RemovalRequestId, - ): PendingRemovalLease? { - val lease = pendingRemoval - val confirmation = state.value.removalConfirmation - if (lease?.phase == RemovalLeasePhase.ReleaseFailed) { + action: RemovalTerminalAction, + ): ClaimedRemoval? { + var rejection: String? = null + var releaseFailed = false + val claim = + removalMutex.withLock { + val lease = pendingRemoval + val confirmation = state.value.removalConfirmation + if (lease?.phase == RemovalLeasePhase.ReleaseFailed) { + releaseFailed = true + return@withLock null + } + if (lease == null || + lease.phase != RemovalLeasePhase.Open || + confirmation == null || + state.value.removalStatus != RemovalStatus.AWAITING_CONFIRMATION + ) { + rejection = "Identity removal confirmation is not available." + return@withLock null + } + val request = lease.request + if (request.identityId != identityId || + request.requestId != requestId || + confirmation.identityId != identityId || + confirmation.requestId != requestId + ) { + rejection = "Identity removal confirmation does not match the current request." + return@withLock null + } + val expired = request.isExpired() + val releaseReason = + when { + expired -> RemovalReleaseReason.Expired + action == RemovalTerminalAction.Cancel -> RemovalReleaseReason.UserCancelled + else -> null + } + lease.phase = if (releaseReason == null) RemovalLeasePhase.Confirming else RemovalLeasePhase.Releasing + lease.releaseReason = releaseReason + val expiryJob = lease.expiryJob + lease.expiryJob = null + ClaimedRemoval(lease, expiryJob, expired, releaseReason) + } + if (releaseFailed) { acceptFailure(ApplicationFailure(removalReleaseFailedProblem()), null) return null } - if (lease == null || - lease.phase != RemovalLeasePhase.Open || - confirmation == null || - state.value.removalStatus != RemovalStatus.AWAITING_CONFIRMATION - ) { - rejectUnavailable("Identity removal confirmation is not available.") + if (claim == null) { + rejectUnavailable(checkNotNull(rejection)) return null } - val request = lease.request - if (request.identityId != identityId || - request.requestId != requestId || - confirmation.identityId != identityId || - confirmation.requestId != requestId - ) { - rejectUnavailable("Identity removal confirmation does not match the current request.") - return null + claim.expiryJob?.cancelAndJoin() + return claim + } + + private suspend fun releaseCurrentRemovalForReplacement() { + var releaseFailed = false + val claim = + removalMutex.withLock { + val lease = pendingRemoval ?: return@withLock null + if (lease.phase == RemovalLeasePhase.ReleaseFailed) { + releaseFailed = true + return@withLock null + } + if (lease.phase != RemovalLeasePhase.Open) { + throw ApplicationFailure(removalReleaseFailedProblem()) + } + lease.phase = RemovalLeasePhase.Releasing + lease.releaseReason = RemovalReleaseReason.Replaced + val expiryJob = lease.expiryJob + lease.expiryJob = null + ClaimedRemoval(lease, expiryJob, expired = false, RemovalReleaseReason.Replaced) + } + if (releaseFailed) throw ApplicationFailure(removalReleaseFailedProblem()) + claim ?: return + claim.expiryJob?.cancelAndJoin() + releaseClaimedRemoval( + ClaimedRemovalRelease(claim.lease, RemovalReleaseReason.Replaced), + RemovalStatus.NONE, + ) + } + + private suspend fun releaseAfterFailedConfirmation(lease: PendingRemovalLease) { + val claimed = + removalMutex.withLock { + if (pendingRemoval !== lease || lease.phase != RemovalLeasePhase.Confirming) return@withLock false + lease.phase = RemovalLeasePhase.Releasing + lease.releaseReason = RemovalReleaseReason.ConfirmFailed + true + } + if (claimed) { + releaseClaimedRemoval( + ClaimedRemovalRelease(lease, RemovalReleaseReason.ConfirmFailed), + RemovalStatus.FAILED, + ) } - return lease } - private suspend fun releaseRemovalRequest( - lease: PendingRemovalLease, + private suspend fun releaseClaimedRemoval( + claim: ClaimedRemovalRelease, resultingStatus: RemovalStatus, ) { - if (pendingRemoval !== lease || lease.phase !in RELEASABLE_REMOVAL_PHASES) { - throw ApplicationFailure(removalReleaseFailedProblem()) - } - lease.phase = RemovalLeasePhase.Releasing + val lease = claim.lease val released = try { runtime.cancelIdentityRemoval(lease.request.requestId) } catch (error: CancellationException) { - quarantineRemovalLease(lease) - acceptFailure(ApplicationFailure(removalReleaseFailedProblem()), null) + quarantineRemovalLease(claim) + if (!closed) acceptFailure(ApplicationFailure(removalReleaseFailedProblem()), null) throw error } catch (_: Exception) { - quarantineRemovalLease(lease) + quarantineRemovalLease(claim) throw ApplicationFailure(removalReleaseFailedProblem()) } if (!released) { - quarantineRemovalLease(lease) + quarantineRemovalLease(claim) throw ApplicationFailure(removalReleaseFailedProblem()) } - lease.phase = RemovalLeasePhase.Released - if (pendingRemoval === lease) pendingRemoval = null + val completed = + removalMutex.withLock { + if (pendingRemoval !== lease || + lease.phase != RemovalLeasePhase.Releasing || + lease.releaseReason != claim.reason + ) { + return@withLock false + } + lease.phase = RemovalLeasePhase.Released + pendingRemoval = null + true + } + if (!completed) throw ApplicationFailure(removalReleaseFailedProblem()) updateState { copy( removalConfirmation = null, @@ -371,14 +474,85 @@ class HarvestCirclePresenter( } } - private fun quarantineRemovalLease(lease: PendingRemovalLease) { - lease.phase = RemovalLeasePhase.ReleaseFailed - updateState { - copy( - removalConfirmation = null, - removalStatus = RemovalStatus.FAILED, - ) + private suspend fun quarantineRemovalLease(claim: ClaimedRemovalRelease) { + val publish = + removalMutex.withLock { + val lease = claim.lease + if (pendingRemoval !== lease || + lease.phase != RemovalLeasePhase.Releasing || + lease.releaseReason != claim.reason + ) { + return@withLock false + } + lease.phase = RemovalLeasePhase.ReleaseFailed + closePhase == PresenterClosePhase.Open + } + if (publish) { + updateState { + copy( + removalConfirmation = null, + removalStatus = RemovalStatus.FAILED, + ) + } + } + } + + private suspend fun expireRemovalLease(lease: PendingRemovalLease) { + while (true) { + delay(removalExpiryDelayMillis(lease.request.expiresAt, clock.now())) + var stillOpenBeforeDeadline = false + val claimed = + removalMutex.withLock { + if (pendingRemoval !== lease || + lease.phase != RemovalLeasePhase.Open || + closePhase != PresenterClosePhase.Open + ) { + return@withLock false + } + if (!lease.request.isExpired()) { + stillOpenBeforeDeadline = true + return@withLock false + } + lease.phase = RemovalLeasePhase.Releasing + lease.releaseReason = RemovalReleaseReason.Expired + true + } + if (!claimed) { + if (stillOpenBeforeDeadline) continue + return + } + updateState { copy(removalConfirmation = null) } + try { + releaseClaimedRemoval( + ClaimedRemovalRelease(lease, RemovalReleaseReason.Expired), + RemovalStatus.FAILED, + ) + acceptFailure(ApplicationFailure(removalExpiredProblem()), null) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + acceptFailure(error, null) + } + return + } + } + + private suspend fun transferRemovalToShutdown() { + val expiryJob = + removalMutex.withLock { + pendingRemoval?.let { lease -> + lease.expiryJob.also { lease.expiryJob = null } + } + } + expiryJob?.cancelAndJoin() + removalMutex.withLock { + pendingRemoval?.let { lease -> + lease.phase = RemovalLeasePhase.TransferredToShutdown + lease.releaseReason = null + } + pendingRemoval = null } + updateState { copy(removalConfirmation = null) } } private fun IdentityRemovalRequest.isExpired(): Boolean = expiresAt.value <= clock.now().value @@ -553,17 +727,51 @@ private data class PendingRetry( private class PendingRemovalLease( val request: IdentityRemovalRequest, var phase: RemovalLeasePhase = RemovalLeasePhase.Open, + var releaseReason: RemovalReleaseReason? = null, + var expiryJob: Job? = null, ) private enum class RemovalLeasePhase { Open, - Releasing, - ReleaseFailed, Confirming, + Releasing, Confirmed, Released, + ReleaseFailed, + TransferredToShutdown, } +private enum class RemovalReleaseReason { + Expired, + UserCancelled, + Replaced, + ConfirmFailed, +} + +private enum class RemovalTerminalAction { + Confirm, + Cancel, +} + +private enum class PresenterClosePhase { + Open, + Closing, + TransferredToShutdown, + Closed, +} + +private data class ClaimedRemoval( + val lease: PendingRemovalLease, + val expiryJob: Job?, + val expired: Boolean, + val releaseReason: RemovalReleaseReason?, +) + +private data class ClaimedRemovalRelease( + val lease: PendingRemovalLease, + val reason: RemovalReleaseReason, +) + private fun IdentityRemovalRequest.toConfirmation(): IdentityRemovalConfirmation = IdentityRemovalConfirmation( identityId = identityId, @@ -621,4 +829,23 @@ private val READY_ROUTES = ) private const val EFFECT_BUFFER_CAPACITY = 8 private const val COMMAND_DEADLINE_MILLIS = 5_000UL -private val RELEASABLE_REMOVAL_PHASES = setOf(RemovalLeasePhase.Open, RemovalLeasePhase.Confirming) + +internal fun removalExpiryDelayMillis( + expiresAt: UnixSeconds, + now: UnixSeconds, +): Long { + if (expiresAt.value <= now.value) return 0L + val remainingSeconds = + if (now.value < 0L && expiresAt.value > Long.MAX_VALUE + now.value) { + Long.MAX_VALUE + } else { + expiresAt.value - now.value + } + return if (remainingSeconds > Long.MAX_VALUE / MILLIS_PER_SECOND) { + Long.MAX_VALUE + } else { + remainingSeconds * MILLIS_PER_SECOND + } +} + +private const val MILLIS_PER_SECOND = 1_000L diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest @@ -196,6 +197,36 @@ class HarvestCirclePresenterTest { } @Test + fun hcEx002CloseTransfersOpenLeaseAcrossCancelledShutdownRetry() = + runTest { + val shutdownGate = CompletableDeferred<Unit>() + val runtime = FakePresenterRuntime(shutdownGate = shutdownGate) + val presenter = presenter(runtime) + runCurrent() + val identityId = IdentityId.fromPublicKeyHex("01".repeat(32)) + presenter.dispatch(HarvestCircleIntent.RequestIdentityRemoval(identityId)) + runCurrent() + + val interrupted = async { presenter.close() } + runCurrent() + interrupted.cancel() + runCurrent() + + assertEquals(0, runtime.removalCancellationCalls) + assertEquals(setOf(RemovalRequestId.from("removal-1")), runtime.activeRemovalRequests) + presenter.dispatch(HarvestCircleIntent.RequestIdentityRemoval(identityId)) + assertEquals(CommandStatus.REJECTED_CLOSED, presenter.state.value.commandStatus) + + shutdownGate.complete(Unit) + val receipt = presenter.close() + + assertTrue(receipt?.closed == true) + assertEquals(2, runtime.shutdownCalls) + assertEquals(0, runtime.removalCancellationCalls) + assertTrue(runtime.activeRemovalRequests.isEmpty()) + } + + @Test fun generatedRecoveryIsOneUseAndClearedOnCancellation() = runTest { val runtime = FakePresenterRuntime() @@ -344,7 +375,7 @@ class HarvestCirclePresenterTest { val identityId = IdentityId.fromPublicKeyHex("01".repeat(32)) presenter.dispatch(HarvestCircleIntent.RequestIdentityRemoval(identityId)) - advanceUntilIdle() + runCurrent() val confirmation = presenter.state.value.removalConfirmation ?: error("removal confirmation") assertEquals(identityId, confirmation.identityId) assertEquals(RemovalRequestId.from("removal-1"), confirmation.requestId) @@ -353,6 +384,7 @@ class HarvestCirclePresenterTest { assertNull(presenter.state.value.removalConfirmation) assertEquals(1, runtime.removalCancellationCalls) + assertTrue(runtime.activeRemovalRequests.isEmpty()) presenter.close() } @@ -365,14 +397,14 @@ class HarvestCirclePresenterTest { val identityId = IdentityId.fromPublicKeyHex("01".repeat(32)) presenter.dispatch(HarvestCircleIntent.RequestIdentityRemoval(identityId)) - advanceUntilIdle() + runCurrent() presenter.dispatch( HarvestCircleIntent.CancelIdentityRemoval( identityId, RemovalRequestId.from("stale-removal"), ), ) - advanceUntilIdle() + runCurrent() assertEquals( RemovalRequestId.from("removal-1"), @@ -385,6 +417,43 @@ class HarvestCirclePresenterTest { } @Test + fun hcEx001UntouchedRemovalExpiresAndReleasesWithoutUserAction() = + runTest { + val runtime = FakePresenterRuntime() + val clock = ApplicationClock { UnixSeconds(10L + testScheduler.currentTime / 1_000L) } + val presenter = presenter(runtime, clock = clock) + runCurrent() + val identityId = IdentityId.fromPublicKeyHex("01".repeat(32)) + + presenter.dispatch(HarvestCircleIntent.RequestIdentityRemoval(identityId)) + runCurrent() + assertEquals(setOf(RemovalRequestId.from("removal-1")), runtime.activeRemovalRequests) + + advanceTimeBy(49_999L) + runCurrent() + assertTrue(presenter.state.value.removalConfirmation != null) + assertEquals(0, runtime.removalCancellationCalls) + + advanceTimeBy(1L) + runCurrent() + + assertNull(presenter.state.value.removalConfirmation) + assertEquals(RemovalStatus.FAILED, presenter.state.value.removalStatus) + assertEquals(CommandStatus.FAILED_TERMINAL, presenter.state.value.commandStatus) + assertEquals("Identity removal confirmation has expired.", presenter.state.value.problem) + assertEquals(listOf("request:removal-1", "cancel:removal-1"), runtime.removalEvents) + assertTrue(runtime.activeRemovalRequests.isEmpty()) + presenter.close() + } + + @Test + fun hcEx003RemovalExpiryDelayIsSaturating() { + assertEquals(0L, removalExpiryDelayMillis(UnixSeconds(10), UnixSeconds(10))) + assertEquals(1_000L, removalExpiryDelayMillis(UnixSeconds(11), UnixSeconds(10))) + assertEquals(Long.MAX_VALUE, removalExpiryDelayMillis(UnixSeconds(Long.MAX_VALUE), UnixSeconds(-1))) + } + + @Test fun hcSc001FailedRemovalRequestExposesNoConfirmation() = runTest { val runtime = FakePresenterRuntime().also { it.removalFailure = problem(retryable = false) } @@ -410,7 +479,7 @@ class HarvestCirclePresenterTest { val identityId = IdentityId.fromPublicKeyHex("01".repeat(32)) presenter.dispatch(HarvestCircleIntent.RequestIdentityRemoval(identityId)) - advanceUntilIdle() + runCurrent() val confirmation = presenter.state.value.removalConfirmation ?: error("removal confirmation") presenter.dispatch(HarvestCircleIntent.ConfirmIdentityRemoval(identityId, confirmation.requestId)) advanceUntilIdle() @@ -418,6 +487,7 @@ class HarvestCirclePresenterTest { assertEquals(listOf(confirmation.requestId), runtime.confirmedRemovalRequests) assertEquals(RemovalStatus.COMPLETED, presenter.state.value.removalStatus) assertNull(presenter.state.value.removalConfirmation) + assertTrue(runtime.activeRemovalRequests.isEmpty()) presenter.close() } @@ -447,7 +517,7 @@ class HarvestCirclePresenterTest { runCurrent() val identityId = IdentityId.fromPublicKeyHex("01".repeat(32)) presenter.dispatch(HarvestCircleIntent.RequestIdentityRemoval(identityId)) - advanceUntilIdle() + runCurrent() val requestId = presenter.state.value.removalConfirmation ?.requestId ?: error("confirmation") @@ -471,7 +541,7 @@ class HarvestCirclePresenterTest { runCurrent() val identityId = IdentityId.fromPublicKeyHex("01".repeat(32)) presenter.dispatch(HarvestCircleIntent.RequestIdentityRemoval(identityId)) - advanceUntilIdle() + runCurrent() val requestId = presenter.state.value.removalConfirmation ?.requestId ?: error("confirmation") @@ -511,7 +581,7 @@ class HarvestCirclePresenterTest { runCurrent() val identityId = IdentityId.fromPublicKeyHex("01".repeat(32)) presenter.dispatch(HarvestCircleIntent.RequestIdentityRemoval(identityId)) - advanceUntilIdle() + runCurrent() val requestId = presenter.state.value.removalConfirmation ?.requestId ?: error("confirmation") @@ -536,7 +606,7 @@ class HarvestCirclePresenterTest { runCurrent() val identityId = IdentityId.fromPublicKeyHex("01".repeat(32)) presenter.dispatch(HarvestCircleIntent.RequestIdentityRemoval(identityId)) - advanceUntilIdle() + runCurrent() val requestId = presenter.state.value.removalConfirmation ?.requestId ?: error("confirmation") @@ -561,9 +631,9 @@ class HarvestCirclePresenterTest { val identityId = IdentityId.fromPublicKeyHex("01".repeat(32)) presenter.dispatch(HarvestCircleIntent.RequestIdentityRemoval(identityId)) - advanceUntilIdle() + runCurrent() presenter.dispatch(HarvestCircleIntent.RequestIdentityRemoval(identityId)) - advanceUntilIdle() + runCurrent() assertEquals( listOf("request:removal-1", "cancel:removal-1", "request:removal-2"), @@ -624,6 +694,7 @@ private class FakePresenterRuntime( var removalFailure: ApplicationProblem? = null val confirmedRemovalRequests = mutableListOf<RemovalRequestId>() val removalEvents = mutableListOf<String>() + val activeRemovalRequests = mutableSetOf<RemovalRequestId>() val importedSecrets = mutableListOf<String>() override suspend fun bootstrap(): ApplicationSnapshot { @@ -640,6 +711,9 @@ private class FakePresenterRuntime( override suspend fun execute(command: ApplicationCommand): ApplicationCommandResult { executeCalls += 1 + if (command is ApplicationCommand.ConfirmIdentityRemoval) { + activeRemovalRequests.remove(command.requestId) + } nextFailure?.let { nextFailure = null throw ApplicationFailure(it) @@ -653,7 +727,9 @@ private class FakePresenterRuntime( when (command) { is ApplicationCommand.ImportLocalIdentity -> importedSecrets += command.secretKey.take() is ApplicationCommand.CancelGeneratedIdentity -> generatedCancellationCalls += 1 - is ApplicationCommand.ConfirmIdentityRemoval -> confirmedRemovalRequests += command.requestId + is ApplicationCommand.ConfirmIdentityRemoval -> { + confirmedRemovalRequests += command.requestId + } else -> Unit } return ApplicationCommandResult.Updated(current) @@ -672,6 +748,7 @@ private class FakePresenterRuntime( removalRequestCalls += 1 val requestId = RemovalRequestId.from("removal-$removalRequestCalls") removalEvents += "request:${requestId.value}" + activeRemovalRequests += requestId return IdentityRemovalRequest( requestId = requestId, identityId = identityId, @@ -686,13 +763,15 @@ private class FakePresenterRuntime( removalEvents += "cancel:${requestId.value}" removalCancellationError?.let { throw it } removalCancellationFailure?.let { throw ApplicationFailure(it) } - return removalCancellationResult + if (!removalCancellationResult) return false + return activeRemovalRequests.remove(requestId) } override suspend fun shutdown(): ShutdownReceipt { shutdownCalled = true shutdownCalls += 1 shutdownGate?.await() + activeRemovalRequests.clear() return ShutdownReceipt(current.revision, closed = true) } diff --git a/tools/xtask/src/lib.rs b/tools/xtask/src/lib.rs @@ -420,7 +420,9 @@ fn product_shell_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<St ), ( "app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt", - &["hcSl002", "hcSl003", "hcSl004", "hcSl005"], + &[ + "hcSl002", "hcSl003", "hcSl004", "hcSl005", "hcEx001", "hcEx002", + ], ), ( "app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt", @@ -468,7 +470,10 @@ fn product_shell_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<St "app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt", &[ "PendingRemovalLease", - "releaseRemovalRequest", + "removalMutex", + "expireRemovalLease", + "releaseClaimedRemoval", + "PresenterClosePhase.TransferredToShutdown", "ImportSecretDraft", ], ),