From 25f206a061c7cea9294af83bc43fdcf8662da791 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 9 Oct 2026 11:08:44 -0500 Subject: [PATCH] fix: retain intake retries for recovered peers --- .../bitkit/repositories/PrivatePaykitRepo.kt | 12 +++ .../repositories/PrivatePaykitRepoTest.kt | 101 ++++++++++++++++++ changelog.d/next/1455.fixed.md | 1 + journeys/contacts/README.md | 2 + 4 files changed, 116 insertions(+) create mode 100644 changelog.d/next/1455.fixed.md diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index b2f6aa8681..cdb17f9d44 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -1462,6 +1462,17 @@ class PrivatePaykitRepo @Inject constructor( retry.prepareEndpoints || retry.refreshReadiness || pendingPrivateMessageDrainKeys(listOf(retry.publicKey), priority = priority).isNotEmpty() + private suspend fun restoreLinkedPeerReadiness( + state: PrivateMessageDrainState, + receiveLinkedPeers: Boolean, + ): Boolean { + val retry = currentCoroutineContext()[PrivateMessageDrainRetry] ?: return receiveLinkedPeers + if (!retry.isMissing || state.linkedPeers[retry.publicKey] != LinkedPeerState.LINKED) return receiveLinkedPeers + retry.isMissing = false + retry.refreshReadiness = retry.interactiveUntil?.let { it > clock.now() } == true + return receiveLinkedPeers || retry.refreshReadiness + } + private suspend fun pendingPrivateMessageDrainKeys( retryKeys: Collection, retryMissingPeers: Boolean = false, @@ -1477,6 +1488,7 @@ class PrivatePaykitRepo @Inject constructor( awaitContactPreparationActive(priority) if (generation != preparationGeneration) return emptySet() if (state == null) return retryKeys + val receiveLinkedPeers = restoreLinkedPeerReadiness(state, receiveLinkedPeers) val missingKey = currentCoroutineContext()[PrivateMessageDrainRetry]?.takeIf { it.isMissing }?.publicKey return retryKeys.filterTo(mutableSetOf()) { retryKey -> if (retryKey == missingKey && retryKey !in state.pendingOutbound) return@filterTo false diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index c51eab7d2c..422e1dd9eb 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -750,6 +750,107 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } } + @Test + fun `unavailable peer that links during queued delivery retries failed intake`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + whenever(clock.now()).thenAnswer { + Instant.fromEpochSeconds(NOW_SECONDS) + testDispatcher.scheduler.currentTime.milliseconds + } + var peerState = LinkedPeerState.NOT_LINKED + whenever(paykitSdkService.linkedPeers()).thenAnswer { listOf(linkedPeer(CONTACT_KEY, peerState)) } + whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)) + .thenAnswer { throw PaykitException.NotFound("not_found", "No App Registry") } + var deliveryPending = true + whenever(paykitSdkService.pendingOutboundPrivateCounterparties()).thenAnswer { + if (deliveryPending) listOf(CONTACT_KEY) else emptyList() + } + whenever(paykitSdkService.processOutboundPrivateMessages(CONTACT_KEY)).thenAnswer { + if (peerState != LinkedPeerState.LINKED) { + throw PaykitException.Transport("offline", "Unavailable homeserver") + } + deliveryPending = false + mock() + } + whenever(paykitSdkService.receivePrivateMessages(CONTACT_KEY)) + .thenAnswer { throw PaykitException.Transport("offline", "Unavailable homeserver") } + .thenReturn(mock()) + + try { + sut.refreshSavedContactEndpoints(CONTACT_KEY, listOf(CONTACT_KEY)).getOrThrow() + runCurrent() + advanceTimeBy(1_000) + runCurrent() + assertTrue(deliveryPending) + verify(paykitSdkService).processOutboundPrivateMessages(CONTACT_KEY, Priority.Interactive) + + peerState = LinkedPeerState.LINKED + advanceTimeBy(3_000) + runCurrent() + assertFalse(deliveryPending) + verify(paykitSdkService).receivePrivateMessages(CONTACT_KEY, Priority.Interactive) + verifyNoInteractions(paymentRequestRepo) + + advanceTimeBy(8_000) + runCurrent() + verify(paykitSdkService, times(2)).receivePrivateMessages(CONTACT_KEY, Priority.Interactive) + verify(paymentRequestRepo).refreshEligibleTarget(CONTACT_KEY) + clearInvocations(paykitSdkService, paymentRequestRepo) + advanceTimeBy(600_000) + runCurrent() + verifyNoInteractions(paykitSdkService, paymentRequestRepo) + } finally { + sut.closeAndClear() + } + } + + @Test + fun `unavailable peer that links after identity check failure retries failed intake`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) + whenever(clock.now()).thenAnswer { + Instant.fromEpochSeconds(NOW_SECONDS) + testDispatcher.scheduler.currentTime.milliseconds + } + var peerState = LinkedPeerState.NOT_LINKED + var failIdentityCheck = false + whenever(paykitSdkService.linkedPeers()).thenAnswer { listOf(linkedPeer(CONTACT_KEY, peerState)) } + whenever(paykitSdkService.ensureLinkWithPeer(CONTACT_KEY)).thenAnswer { + failIdentityCheck = true + throw PaykitException.NotFound("not_found", "No App Registry") + } + whenever(paykitSdkService.identityStatus()).thenAnswer { + if (failIdentityCheck) throw PaykitException.Transport("offline", "Unavailable identity") + IdentityStatus(OWN_KEY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE) + } + whenever(paykitSdkService.receivePrivateMessages(CONTACT_KEY)) + .thenAnswer { throw PaykitException.Transport("offline", "Unavailable homeserver") } + .thenReturn(mock()) + + try { + sut.refreshSavedContactEndpoints(CONTACT_KEY, listOf(CONTACT_KEY)).getOrThrow() + runCurrent() + assertTrue(failIdentityCheck) + verifyNoInteractions(paymentRequestRepo) + + failIdentityCheck = false + peerState = LinkedPeerState.LINKED + advanceTimeBy(1_000) + runCurrent() + verify(paykitSdkService).receivePrivateMessages(CONTACT_KEY, Priority.Interactive) + verifyNoInteractions(paymentRequestRepo) + + advanceTimeBy(3_000) + runCurrent() + verify(paykitSdkService, times(2)).receivePrivateMessages(CONTACT_KEY, Priority.Interactive) + verify(paymentRequestRepo).refreshEligibleTarget(CONTACT_KEY) + verify(paykitSdkService, never()).processOutboundPrivateMessages(any(), any()) + clearInvocations(paykitSdkService, paymentRequestRepo) + advanceTimeBy(600_000) + runCurrent() + verifyNoInteractions(paykitSdkService, paymentRequestRepo) + } finally { + sut.closeAndClear() + } + } + @Test fun `blocked peer retires an unpublished explicit retry`() = test { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true, publicPaykitLightningEnabled = false) diff --git a/changelog.d/next/1455.fixed.md b/changelog.d/next/1455.fixed.md new file mode 100644 index 0000000000..3a879592e5 --- /dev/null +++ b/changelog.d/next/1455.fixed.md @@ -0,0 +1 @@ +Fixed contact readiness retries stopping after an unavailable contact reconnects. diff --git a/journeys/contacts/README.md b/journeys/contacts/README.md index 11de80e79a..5cdbdcf0cc 100644 --- a/journeys/contacts/README.md +++ b/journeys/contacts/README.md @@ -53,6 +53,8 @@ other peers must continue. Repeat with an existing unlinked peer record whose re authorization is absent. That stored row alone must not keep the retry running. If the same peer has queued outbound work, preserve delivery retries until it drains. Inspecting retry retirement requires an instrumented fixture. +If that peer becomes linked while delivery is pending, drain its queue and fail intake once within +the foreground window: intake must retry and signal readiness only after success. Hold an unrelated contact's background preparation in progress, then open a saved, linked contact and request or pay it. The selected contact must be eligible for its own lookup before the full