Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions Bitkit/Services/PrivatePaykitService+Contacts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ extension PrivatePaykitService {
var retryIndex = 0
var foregroundUntil: Date?
var expectedIdentity: String?
var isMissing = false

func priority(at now: Date) -> PaykitSdkOperationLock.Priority {
foregroundUntil.map { $0 > now } == true ? .interactive : .background
Expand Down Expand Up @@ -968,6 +969,7 @@ extension PrivatePaykitService {
retry.expectedIdentity = identity
retry.nextAttemptAt = now
retry.retryIndex = -1
retry.isMissing = false
unavailableLinkRetryAt[publicKey] = nil
pendingMessageDrainRetries[publicKey] = retry
startPendingPrivateMessageDrainRetries(reason: "contact link")
Expand Down Expand Up @@ -1028,12 +1030,16 @@ extension PrivatePaykitService {
guard let retry = pendingMessageDrainRetries[publicKey] else { return }
let generation = preparationGeneration
var identityInspectionFailed = false
var linkUnavailable = retry.isMissing
Comment thread
ben-kaufman marked this conversation as resolved.
var inspectedSchedulingGeneration: Int?
defer {
if identityInspectionFailed, isMessageRetryCurrent(publicKey: publicKey, id: retry.id, generation: generation),
if isMessageRetryCurrent(publicKey: publicKey, id: retry.id, generation: generation),
pendingMessageDrainRetries[publicKey]?.foregroundUntil == retry.foregroundUntil
{
pendingMessageDrainRetries[publicKey]?.completeAttempt(at: messageRetryOperations.now())
pendingMessageDrainRetries[publicKey]?.isMissing = linkUnavailable
if identityInspectionFailed {
pendingMessageDrainRetries[publicKey]?.completeAttempt(at: messageRetryOperations.now())
}
}
}
let isCurrent: () async -> Bool = {
Expand Down Expand Up @@ -1073,7 +1079,6 @@ extension PrivatePaykitService {
linkPreparationWaiters[publicKey]?[id] = nil
guard await isCurrent() else { return }
}
var linkUnavailable = false
// Another identity operation can run while any SDK call, including a read, is suspended.
let operations = PrivateMessageDrainOperations(
ensureLink: {
Expand Down Expand Up @@ -1129,9 +1134,18 @@ extension PrivatePaykitService {
priority: .background, operations: operations, isCurrent: isCurrent
)
schedulingSnapshot?.expectedIdentity = retry.expectedIdentity
let remainingKeys = schedulingSnapshot?.drainKeys(
[publicKey], retryMissingPeers: retry.expectedIdentity != nil && !linkUnavailable
) ?? [publicKey]
if schedulingSnapshot?.peers.contains(where: {
$0.state == .linked && PubkyPublicKeyFormat.normalized($0.counterparty) == publicKey
}) == true {
linkUnavailable = false
}
let remainingKeys: Set<String> = if let schedulingSnapshot, linkUnavailable, !schedulingSnapshot.pendingOutbound.contains(publicKey) {
[]
} else {
schedulingSnapshot?.drainKeys(
[publicKey], retryMissingPeers: retry.expectedIdentity != nil && !linkUnavailable
) ?? [publicKey]
}
guard await isCurrent() else { return }
guard pendingMessageDrainRetries[publicKey]?.foregroundUntil == retry.foregroundUntil else { return }
let needsForegroundIntake = !linkUnavailable && retry.priority(at: messageRetryOperations.now()) == .interactive && !received
Expand Down
129 changes: 128 additions & 1 deletion BitkitTests/PrivatePaykitServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ final class PrivatePaykitServiceTests: XCTestCase {
func testExplicitRetryRetiresMissingPeerDuringCooldown() async {
PrivatePaykitService.setContactSharingCleanupPending(false)
let key = "pubky" + String(repeating: "y", count: 52)
let otherKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
for hasPendingOutbound in [false, true] {
var now = Date(timeIntervalSince1970: 100)
var handshakes = 0
Expand All @@ -119,7 +120,8 @@ final class PrivatePaykitServiceTests: XCTestCase {
handshakes += 1
throw PaykitError.NotFound(code: "not_found", context: "No App Registry")
},
pendingOutbound: { hasPendingOutbound ? [key] : [] }, linkedPeers: { [] },
pendingOutbound: { hasPendingOutbound ? [key, otherKey] : [otherKey] },
linkedPeers: { [self.drainPeer(key, state: .notLinked)] },
processPending: { _ in deliveries += 1 },
receive: { _ in XCTFail("No linked peer") }
)
Expand All @@ -141,6 +143,87 @@ final class PrivatePaykitServiceTests: XCTestCase {
}
}

func testMissingPeerRetiresAfterQueuedDeliveryDrains() async {
PrivatePaykitService.setContactSharingCleanupPending(false)
let key = "pubky" + String(repeating: "y", count: 52)
var now = Date(timeIntervalSince1970: 100)
var handshakes = 0
var deliveries = 0
var sleeps = 0
var hasPendingOutbound = true
var readFailed = false
var confirmedEmpty = false
var failIdentityInspection = false
var identityInspectionFailed = false
let settled = expectation(description: "Queued delivery drains and retry retires")
settled.assertForOverFulfill = false
let (resume, continuation) = AsyncStream<Void>.makeStream()
defer { continuation.finish() }
let service = PrivatePaykitService(messageRetryOperations: .init(
now: { now },
sleep: { delay in
sleeps += 1
now = now.addingTimeInterval(TimeInterval(delay) / 1_000_000_000)
if sleeps == 6 {
settled.fulfill()
for await _ in resume {}
}
},
currentPublicKey: { _ in
if failIdentityInspection {
failIdentityInspection = false
identityInspectionFailed = true
throw PaykitError.Transport(code: "offline", context: "Unavailable homeserver")
}
return "identity"
},
drain: { _ in
.init(
ensureLink: { _ in
handshakes += 1
failIdentityInspection = true
throw PaykitError.NotFound(code: "not_found", context: "No App Registry")
},
pendingOutbound: {
if deliveries == 2, !readFailed {
readFailed = true
throw PaykitError.Transport(code: "offline", context: "Unavailable homeserver")
}
if readFailed { confirmedEmpty = !hasPendingOutbound }
return hasPendingOutbound ? [key] : []
},
linkedPeers: { [self.drainPeer(key, state: .notLinked)] },
processPending: {
XCTAssertEqual($0, key)
deliveries += 1
if deliveries == 1 {
throw PaykitError.Transport(code: "offline", context: "Unavailable homeserver")
}
hasPendingOutbound = false
},
receive: { _ in XCTFail("No linked peer") }
)
},
didLink: { _, _ in XCTFail("Missing peer cannot link") }
))
_ = await service.rememberSavedContacts([key], replacing: true)
await service.scheduleExplicitContactLink(publicKey: key, identity: "identity")
let task = await service.pendingMessageDrainRetryTask
let completion = Task { await task?.value; settled.fulfill() }
await fulfillment(of: [settled], timeout: 2)
let retry = await service.pendingMessageDrainRetries[key]
XCTAssertNil(retry)
XCTAssertEqual(handshakes, 1)
XCTAssertEqual(deliveries, 2)
XCTAssertTrue(readFailed)
XCTAssertTrue(confirmedEmpty)
XCTAssertTrue(identityInspectionFailed)
XCTAssertFalse(hasPendingOutbound)
await service.invalidateContactPreparation()
continuation.finish()
await completion.value
}

func testExplicitRetrySurvivesTransportCooldown() async {
PrivatePaykitService.setContactSharingCleanupPending(false)
let key = "pubky" + String(repeating: "y", count: 52)
Expand Down Expand Up @@ -419,6 +502,50 @@ final class PrivatePaykitServiceTests: XCTestCase {
}
}

func testRecoveredMissingPeerRetriesFailedIntake() async {
PrivatePaykitService.setContactSharingCleanupPending(false)
let key = "pubky" + String(repeating: "y", count: 52)
var now = Date(timeIntervalSince1970: 100)
var linked = false
var pending = true
var receives = 0
var completions = 0
let service = PrivatePaykitService(messageRetryOperations: .init(
now: { now },
sleep: { delay in
now = now.addingTimeInterval(TimeInterval(delay) / 1_000_000_000)
linked = true
},
currentPublicKey: { _ in "identity" },
drain: { _ in
.init(
ensureLink: { _ in throw PaykitError.NotFound(code: "missing", context: "Peer unavailable") },
pendingOutbound: { pending ? [key] : [] },
linkedPeers: { [self.drainPeer(key, state: linked ? .linked : .notLinked)] },
processPending: { _ in
guard linked else { throw PaykitError.Transport(code: "offline", context: "Delivery unavailable") }
pending = false
},
receive: { _ in
receives += 1
if receives == 1 { throw PaykitError.Transport(code: "offline", context: "Intake unavailable") }
}
)
},
didLink: { _, _ in completions += 1 }
))
_ = await service.rememberSavedContacts([key], replacing: true)
await service.scheduleExplicitContactLink(publicKey: key, identity: "identity")
let task = await service.pendingMessageDrainRetryTask
await task?.value
XCTAssertFalse(pending)
XCTAssertEqual(receives, 2)
XCTAssertEqual(completions, 1)
let remaining = await service.pendingMessageDrainRetries[key]
XCTAssertNil(remaining)
await service.invalidateContactPreparation()
}

func testExplicitLinkWindowUsesExistingBackoffAndDoesNotExtendOnDuplicate() async throws {
PrivatePaykitService.setContactSharingCleanupPending(false)
let now = Date(timeIntervalSince1970: 100)
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/911.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Stopped unnecessary background retries for unavailable contacts while preserving queued message delivery.
11 changes: 8 additions & 3 deletions journeys/contacts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,9 +163,14 @@ Target eligibility discovery remains owned by the existing request manager. It c
but does not automatically retry a failed targeted refresh; a later full refresh or contact
eligibility lookup can retry it. Successful intake does not guarantee successful discovery.

Return NotFound from the explicit retry's link attempt for a peer with no SDK record or pending outbound work:
the retry retires instead of polling indefinitely. Pending outbound work must remain scheduled,
and the existing unavailable-peer cooldown still applies. A transport failure during publication
Return NotFound from the explicit retry's link attempt for a peer with no pending outbound work:
the retry retires instead of polling indefinitely, whether its SDK record is absent or still unlinked.
An unrelated peer's pending outbound must not keep the missing peer's retry alive. Pending outbound
for that same peer must remain scheduled through transient delivery failures and retire after it drains;
failed scheduling reads must keep the retry until a fresh read confirms no delivery remains.
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.
A later explicit refresh can try linking again, and the existing unavailable-peer cooldown still applies. A transport failure during publication
must not retire the explicit retry; it attempts linking again after the cooldown. Repeat identity changes during peer
and outbound reads: no following operation or readiness notification may use the old identity.
Invalid peer-key or recovery metadata must not be presented as ordinary link recovery.
Expand Down
Loading