mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 20:45:19 +00:00
Clear peer-scoped retry state on acks without a conversation
After force-quit -> relaunch the durable outbox is restored while the in-memory conversation store is not. updateAcknowledgedMessageDeliveryStatus gated the router-side clear on the conversation lookup, so a delivery ack for a retried message was discarded and the already-delivered message re-sent on every flush/auth event until the attempt cap marked it failed. Always run the peer-scoped router clear (markDelivered(_:from:) only terminalizes the acking peer's own queues); gate only the UI status transition and the media-retry release on the conversation holding the message, mirroring the Nostr path in ChatPrivateConversationCoordinator.handleDelivered. Also drop the dead allowedPeerIDs parameter on MessageRouter.clearRetainedMessage (only ever called with nil; its filter branch paired a scoped in-memory removal with an unscoped store tombstone) and the redundant manual ==/hash on PeerMessageKey. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -37,15 +37,6 @@ final class MessageRouter {
|
|||||||
private struct PeerMessageKey: Hashable {
|
private struct PeerMessageKey: Hashable {
|
||||||
let peerID: PeerID
|
let peerID: PeerID
|
||||||
let messageID: String
|
let messageID: String
|
||||||
|
|
||||||
static func == (lhs: PeerMessageKey, rhs: PeerMessageKey) -> Bool {
|
|
||||||
lhs.peerID == rhs.peerID && lhs.messageID == rhs.messageID
|
|
||||||
}
|
|
||||||
|
|
||||||
func hash(into hasher: inout Hasher) {
|
|
||||||
hasher.combine(peerID)
|
|
||||||
hasher.combine(messageID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private let transports: [Transport]
|
private let transports: [Transport]
|
||||||
@@ -399,7 +390,7 @@ final class MessageRouter {
|
|||||||
/// every copy of the message. Authenticated remote receipts must use the
|
/// every copy of the message. Authenticated remote receipts must use the
|
||||||
/// peer-bound overload below instead.
|
/// peer-bound overload below instead.
|
||||||
func markDelivered(_ messageID: String) {
|
func markDelivered(_ messageID: String) {
|
||||||
clearRetainedMessage(messageID, allowedPeerIDs: nil)
|
clearRetainedMessage(messageID)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stops retaining a message only for the authenticated conversation
|
/// Stops retaining a message only for the authenticated conversation
|
||||||
@@ -411,15 +402,9 @@ final class MessageRouter {
|
|||||||
_ = markDelivered(messageID, for: Array(peerIDs))
|
_ = markDelivered(messageID, for: Array(peerIDs))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func clearRetainedMessage(
|
private func clearRetainedMessage(_ messageID: String) {
|
||||||
_ messageID: String,
|
|
||||||
allowedPeerIDs: Set<PeerID>?
|
|
||||||
) {
|
|
||||||
var cleared = false
|
var cleared = false
|
||||||
for (peerID, queue) in outbox {
|
for (peerID, queue) in outbox {
|
||||||
if let allowedPeerIDs, !allowedPeerIDs.contains(peerID) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
let filtered = queue.filter { $0.messageID != messageID }
|
let filtered = queue.filter { $0.messageID != messageID }
|
||||||
guard filtered.count != queue.count else { continue }
|
guard filtered.count != queue.count else { continue }
|
||||||
outbox[peerID] = filtered.isEmpty ? nil : filtered
|
outbox[peerID] = filtered.isEmpty ? nil : filtered
|
||||||
|
|||||||
@@ -42,8 +42,15 @@ protocol ChatDeliveryContext: AnyObject {
|
|||||||
/// Confirms receipt so the message router stops retaining the message for resend.
|
/// Confirms receipt so the message router stops retaining the message for resend.
|
||||||
func markMessageDelivered(_ messageID: String)
|
func markMessageDelivered(_ messageID: String)
|
||||||
/// Peer-bound form for authenticated remote receipts. Only the supplied
|
/// Peer-bound form for authenticated remote receipts. Only the supplied
|
||||||
/// conversation aliases may have retained state terminalized.
|
/// conversation aliases may have retained state terminalized. This is the
|
||||||
|
/// router-side clear only: it is safe without a conversation lookup
|
||||||
|
/// because the router scopes removal to the acking peer's own queues.
|
||||||
func markMessageDelivered(_ messageID: String, from peerIDs: Set<PeerID>)
|
func markMessageDelivered(_ messageID: String, from peerIDs: Set<PeerID>)
|
||||||
|
/// Releases the media reconnect retry for a private transfer. Unlike the
|
||||||
|
/// router clear above this is keyed only by the stable media message ID,
|
||||||
|
/// so callers must first bind the receipt to one of our outgoing
|
||||||
|
/// conversations for the acking peer.
|
||||||
|
func confirmPrivateMediaDelivery(_ messageID: String)
|
||||||
/// Returns true only when `messageID` is one of our outgoing messages in
|
/// Returns true only when `messageID` is one of our outgoing messages in
|
||||||
/// at least one of the authenticated peer's direct-conversation aliases.
|
/// at least one of the authenticated peer's direct-conversation aliases.
|
||||||
func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set<PeerID>) -> Bool
|
func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set<PeerID>) -> Bool
|
||||||
@@ -89,9 +96,9 @@ extension ChatViewModel: ChatDeliveryContext {
|
|||||||
|
|
||||||
func markMessageDelivered(_ messageID: String, from peerIDs: Set<PeerID>) {
|
func markMessageDelivered(_ messageID: String, from peerIDs: Set<PeerID>) {
|
||||||
messageRouter.markDelivered(messageID, from: peerIDs)
|
messageRouter.markDelivered(messageID, from: peerIDs)
|
||||||
// The caller has already bound this receipt to one of our outgoing
|
}
|
||||||
// conversations. Release the matching media retry only after that
|
|
||||||
// peer-scoped validation and accepted delivery-status transition.
|
func confirmPrivateMediaDelivery(_ messageID: String) {
|
||||||
mediaTransferCoordinator.confirmPrivateMediaDelivery(
|
mediaTransferCoordinator.confirmPrivateMediaDelivery(
|
||||||
messageID: messageID
|
messageID: messageID
|
||||||
)
|
)
|
||||||
@@ -165,10 +172,18 @@ final class ChatDeliveryCoordinator {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Applies an authenticated remote delivery/read receipt only when it
|
/// Applies an authenticated remote delivery/read receipt. The durable
|
||||||
/// belongs to one of our outgoing messages in that peer's conversation.
|
/// retry state is always released for the acking peer's own aliases
|
||||||
/// Retry state is cleared after, never before, the status transition is
|
/// (`markMessageDelivered(_:from:)` is peer-scoped on the router side, so
|
||||||
/// accepted by the store.
|
/// a receipt can only terminalize messages queued for that peer). Only the
|
||||||
|
/// UI status transition and the media-retry release are gated on the
|
||||||
|
/// in-memory conversation holding the message as one of ours: after a
|
||||||
|
/// force-quit → relaunch the durable outbox is restored
|
||||||
|
/// while the conversation may not be, and discarding the ack there would
|
||||||
|
/// re-send an already-delivered message on every flush/auth event until
|
||||||
|
/// the attempt cap marks it failed. Mirrors the Nostr path
|
||||||
|
/// (`ChatPrivateConversationCoordinator.handleDelivered`), which clears
|
||||||
|
/// retained state unconditionally.
|
||||||
@MainActor
|
@MainActor
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func updateAcknowledgedMessageDeliveryStatus(
|
func updateAcknowledgedMessageDeliveryStatus(
|
||||||
@@ -182,8 +197,9 @@ final class ChatDeliveryCoordinator {
|
|||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
guard !peerIDAliases.isEmpty,
|
guard !peerIDAliases.isEmpty else { return false }
|
||||||
context.isOutgoingPrivateMessage(messageID, toAny: peerIDAliases),
|
context.markMessageDelivered(messageID, from: peerIDAliases)
|
||||||
|
guard context.isOutgoingPrivateMessage(messageID, toAny: peerIDAliases),
|
||||||
context.setDeliveryStatus(
|
context.setDeliveryStatus(
|
||||||
status,
|
status,
|
||||||
forMessageID: messageID,
|
forMessageID: messageID,
|
||||||
@@ -191,7 +207,10 @@ final class ChatDeliveryCoordinator {
|
|||||||
) else {
|
) else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
context.markMessageDelivered(messageID, from: peerIDAliases)
|
// The receipt is now bound to one of our outgoing conversations for
|
||||||
|
// the acking peer; only then release the media reconnect retry, whose
|
||||||
|
// stable message ID is not peer-scoped.
|
||||||
|
context.confirmPrivateMediaDelivery(messageID)
|
||||||
context.notifyUIChanged()
|
context.notifyUIChanged()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ import BitFoundation
|
|||||||
// MARK: - Test Helpers
|
// MARK: - Test Helpers
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
private func makeTestableViewModel(
|
||||||
let keychain = MockKeychain()
|
keychain injectedKeychain: MockKeychain? = nil,
|
||||||
|
outboxStore: MessageOutboxStore? = nil
|
||||||
|
) -> (viewModel: ChatViewModel, transport: MockTransport) {
|
||||||
|
let keychain = injectedKeychain ?? MockKeychain()
|
||||||
let keychainHelper = MockKeychainHelper()
|
let keychainHelper = MockKeychainHelper()
|
||||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
||||||
let identityManager = MockIdentityManager(keychain)
|
let identityManager = MockIdentityManager(keychain)
|
||||||
@@ -24,7 +27,8 @@ private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: Mo
|
|||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
idBridge: idBridge,
|
idBridge: idBridge,
|
||||||
identityManager: identityManager,
|
identityManager: identityManager,
|
||||||
transport: transport
|
transport: transport,
|
||||||
|
outboxStore: outboxStore
|
||||||
)
|
)
|
||||||
|
|
||||||
return (viewModel, transport)
|
return (viewModel, transport)
|
||||||
@@ -454,6 +458,65 @@ struct ChatViewModelDeliveryStatusTests {
|
|||||||
#expect(viewModel.sentReadReceipts == ["keep-receipt"])
|
#expect(viewModel.sentReadReceipts == ["keep-receipt"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Relaunch-Restored Outbox Tests
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func deliveryAckAfterRelaunchClearsRestoredOutboxWithoutConversation() async {
|
||||||
|
// Force-quit → relaunch: the durable outbox restores the retained DM,
|
||||||
|
// but the in-memory conversation store starts empty. The delivery ack
|
||||||
|
// must still clear the router's retained copy — gating it on the
|
||||||
|
// conversation lookup would leave the entry re-sending on every
|
||||||
|
// flush/auth event until the attempt cap marks it failed despite
|
||||||
|
// delivery.
|
||||||
|
let fileURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("relaunch-ack-\(UUID().uuidString).sealed")
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let peerID = PeerID(str: "0102030405060708")
|
||||||
|
let messageID = "relaunch-retained-dm"
|
||||||
|
|
||||||
|
// Pre-quit state: one retained private message on disk.
|
||||||
|
MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([
|
||||||
|
peerID: [MessageOutboxStore.QueuedMessage(
|
||||||
|
content: "Sent before force-quit",
|
||||||
|
nickname: "Peer",
|
||||||
|
messageID: messageID,
|
||||||
|
timestamp: Date()
|
||||||
|
)]
|
||||||
|
])
|
||||||
|
|
||||||
|
// Relaunch: fresh view model over the same durable store.
|
||||||
|
let (viewModel, transport) = makeTestableViewModel(
|
||||||
|
keychain: keychain,
|
||||||
|
outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL)
|
||||||
|
)
|
||||||
|
#expect(viewModel.privateChats[peerID] == nil)
|
||||||
|
|
||||||
|
// The peer's delivery ack arrives with no conversation in the store.
|
||||||
|
// No UI transition is possible (and none must crash), but the durable
|
||||||
|
// retry state has to clear.
|
||||||
|
let didUpdate = viewModel.deliveryCoordinator
|
||||||
|
.updateAcknowledgedMessageDeliveryStatus(
|
||||||
|
messageID,
|
||||||
|
status: .delivered(to: "Peer", at: Date()),
|
||||||
|
from: [peerID]
|
||||||
|
)
|
||||||
|
#expect(!didUpdate)
|
||||||
|
#expect(viewModel.privateChats[peerID] == nil)
|
||||||
|
|
||||||
|
// Neither a flush nor a replacement-handshake auth event may re-send
|
||||||
|
// the already-delivered message.
|
||||||
|
transport.connectedPeers.insert(peerID)
|
||||||
|
transport.securePeers = [peerID]
|
||||||
|
viewModel.messageRouter.flushOutbox(for: peerID)
|
||||||
|
viewModel.messageRouter.retrySecurePrivateMessagesAfterAuthentication(for: [peerID])
|
||||||
|
#expect(transport.sentPrivateMessages.isEmpty)
|
||||||
|
|
||||||
|
// The clear reached the durable snapshot: the next relaunch restores
|
||||||
|
// nothing.
|
||||||
|
#expect(MessageOutboxStore(keychain: keychain, fileURL: fileURL).load().isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Public Timeline Status Tests
|
// MARK: - Public Timeline Status Tests
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
@@ -709,6 +772,7 @@ private final class MockChatDeliveryContext: ChatDeliveryContext {
|
|||||||
private(set) var notifyUIChangedCount = 0
|
private(set) var notifyUIChangedCount = 0
|
||||||
private(set) var markedDeliveredMessageIDs: [String] = []
|
private(set) var markedDeliveredMessageIDs: [String] = []
|
||||||
private(set) var peerBoundDeliveredMessages: [(messageID: String, peerIDs: Set<PeerID>)] = []
|
private(set) var peerBoundDeliveredMessages: [(messageID: String, peerIDs: Set<PeerID>)] = []
|
||||||
|
private(set) var confirmedMediaMessageIDs: [String] = []
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||||
@@ -754,6 +818,10 @@ private final class MockChatDeliveryContext: ChatDeliveryContext {
|
|||||||
peerBoundDeliveredMessages.append((messageID, peerIDs))
|
peerBoundDeliveredMessages.append((messageID, peerIDs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func confirmPrivateMediaDelivery(_ messageID: String) {
|
||||||
|
confirmedMediaMessageIDs.append(messageID)
|
||||||
|
}
|
||||||
|
|
||||||
func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set<PeerID>) -> Bool {
|
func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set<PeerID>) -> Bool {
|
||||||
peerIDs.contains { peerID in
|
peerIDs.contains { peerID in
|
||||||
contextMessages(for: peerID).contains { message in
|
contextMessages(for: peerID).contains { message in
|
||||||
@@ -963,13 +1031,19 @@ struct ChatDeliveryCoordinatorContextTests {
|
|||||||
)
|
)
|
||||||
|
|
||||||
#expect(isSent(coordinator.deliveryStatus(for: messageID)))
|
#expect(isSent(coordinator.deliveryStatus(for: messageID)))
|
||||||
#expect(context.peerBoundDeliveredMessages.isEmpty)
|
// The router-side clear runs, but bound only to the wrong peer's own
|
||||||
|
// aliases — a scoped no-op that cannot touch the intended peer's
|
||||||
|
// retained copy. Status, media retry, and UI stay untouched.
|
||||||
|
#expect(context.peerBoundDeliveredMessages.count == 1)
|
||||||
|
#expect(context.peerBoundDeliveredMessages[0].messageID == messageID)
|
||||||
|
#expect(context.peerBoundDeliveredMessages[0].peerIDs == [otherPeer])
|
||||||
#expect(context.markedDeliveredMessageIDs.isEmpty)
|
#expect(context.markedDeliveredMessageIDs.isEmpty)
|
||||||
|
#expect(context.confirmedMediaMessageIDs.isEmpty)
|
||||||
#expect(context.notifyUIChangedCount == 0)
|
#expect(context.notifyUIChangedCount == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func rejectedStaleReceiptDoesNotTerminalizeRetryState() async {
|
func rejectedStaleReceiptDoesNotDowngradeStatusOrNotify() async {
|
||||||
let context = MockChatDeliveryContext()
|
let context = MockChatDeliveryContext()
|
||||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||||
let peerID = PeerID(str: "0102030405060708")
|
let peerID = PeerID(str: "0102030405060708")
|
||||||
@@ -990,8 +1064,14 @@ struct ChatDeliveryCoordinatorContextTests {
|
|||||||
|
|
||||||
#expect(!didUpdate)
|
#expect(!didUpdate)
|
||||||
#expect(isRead(coordinator.deliveryStatus(for: messageID)))
|
#expect(isRead(coordinator.deliveryStatus(for: messageID)))
|
||||||
#expect(context.peerBoundDeliveredMessages.isEmpty)
|
// The peer-scoped router clear re-runs (idempotent: the earlier read
|
||||||
|
// receipt already emptied this peer's retained copy), but the stale
|
||||||
|
// delivered ack must not downgrade the read status, release media, or
|
||||||
|
// notify the UI.
|
||||||
|
#expect(context.peerBoundDeliveredMessages.count == 1)
|
||||||
|
#expect(context.peerBoundDeliveredMessages[0].peerIDs == [peerID])
|
||||||
#expect(context.markedDeliveredMessageIDs.isEmpty)
|
#expect(context.markedDeliveredMessageIDs.isEmpty)
|
||||||
|
#expect(context.confirmedMediaMessageIDs.isEmpty)
|
||||||
#expect(context.notifyUIChangedCount == 0)
|
#expect(context.notifyUIChangedCount == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -750,6 +750,7 @@ private final class PerfDeliveryContext: ChatDeliveryContext {
|
|||||||
func notifyUIChanged() {}
|
func notifyUIChanged() {}
|
||||||
func markMessageDelivered(_ messageID: String) {}
|
func markMessageDelivered(_ messageID: String) {}
|
||||||
func markMessageDelivered(_ messageID: String, from peerIDs: Set<PeerID>) {}
|
func markMessageDelivered(_ messageID: String, from peerIDs: Set<PeerID>) {}
|
||||||
|
func confirmPrivateMediaDelivery(_ messageID: String) {}
|
||||||
func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set<PeerID>) -> Bool {
|
func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set<PeerID>) -> Bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user