mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 04:45:20 +00:00
Retry unacknowledged DMs after Noise replacement (#1462)
DMs sent through an established Noise session are retained in the durable MessageRouter outbox until an authenticated delivery/read ack, and retried under the same message ID when the peer's replacement handshake authenticates — fixing DMs silently lost into stale local sessions after a remote app restart. Mesh ack handling is peer-scoped end-to-end (alias-scoped delivery status, peer-bound markDelivered, PeerMessageKey retry state), so a colliding message ID from another conversation can no longer clear or promote foreign state; bridge-drop dedup keys are recipient-scoped with hashed persistence. Includes review fix: acks arriving after a relaunch (durable outbox restored, conversation not) now clear the peer-scoped router entry unconditionally — only the UI status transition is gated on conversation presence — so a delivered message can no longer re-send to the attempt cap and be marked failed despite delivery. Regression test simulates the relaunch through real store persistence; the dead allowedPeerIDs parameter (unscoped-tombstone trap) is removed.
This commit is contained in:
@@ -203,6 +203,134 @@ struct BridgeCourierServiceTests {
|
||||
#expect(confirmed.sealRequests.isEmpty)
|
||||
}
|
||||
|
||||
@Test func sameMessageIDIsScopedByRecipientAcrossRejectedActiveAndPersistedState() {
|
||||
let fileURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("bridge-dedup-\(UUID().uuidString).json")
|
||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||
let rejectedKey = Fixture.randomKey()
|
||||
let firstKey = Fixture.randomKey()
|
||||
let secondKey = Fixture.randomKey()
|
||||
let thirdKey = Fixture.randomKey()
|
||||
let messageID = "recipient-scoped-collision"
|
||||
|
||||
let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
|
||||
fixture.sealResult = makeEnvelope(
|
||||
recipientKey: rejectedKey,
|
||||
ciphertext: Data(
|
||||
repeating: 7,
|
||||
count: BridgeCourierService.Limits.maxDropEnvelopeBytes + 1
|
||||
)
|
||||
)
|
||||
var rejectedResults: [Bool] = []
|
||||
fixture.service.depositDrop(
|
||||
content: "rejected",
|
||||
messageID: messageID,
|
||||
recipientNoiseKey: rejectedKey
|
||||
) { rejectedResults.append($0) }
|
||||
#expect(rejectedResults == [false])
|
||||
|
||||
fixture.sealResult = makeEnvelope(recipientKey: firstKey)
|
||||
fixture.automaticPublishResult = nil
|
||||
var firstResults: [Bool] = []
|
||||
var secondResults: [Bool] = []
|
||||
var duplicateFirstResults: [Bool] = []
|
||||
fixture.service.depositDrop(
|
||||
content: "first",
|
||||
messageID: messageID,
|
||||
recipientNoiseKey: firstKey
|
||||
) { firstResults.append($0) }
|
||||
fixture.service.depositDrop(
|
||||
content: "second",
|
||||
messageID: messageID,
|
||||
recipientNoiseKey: secondKey
|
||||
) { secondResults.append($0) }
|
||||
fixture.service.depositDrop(
|
||||
content: "first duplicate",
|
||||
messageID: messageID,
|
||||
recipientNoiseKey: firstKey
|
||||
) { duplicateFirstResults.append($0) }
|
||||
|
||||
#expect(fixture.publishedEvents.count == 2)
|
||||
#expect(fixture.pendingPublishCompletions.count == 2)
|
||||
#expect(duplicateFirstResults == [false])
|
||||
#expect(firstResults.isEmpty)
|
||||
#expect(secondResults.isEmpty)
|
||||
|
||||
fixture.resolveNextPublish(true)
|
||||
fixture.resolveNextPublish(true)
|
||||
#expect(firstResults == [true])
|
||||
#expect(secondResults == [true])
|
||||
fixture.service.flushDedupSnapshot()
|
||||
|
||||
let relaunched = Fixture(
|
||||
dedupStore: BridgeDropDedupStore(fileURL: fileURL)
|
||||
)
|
||||
relaunched.sealResult = makeEnvelope(recipientKey: thirdKey)
|
||||
var relaunchResults: [Bool] = []
|
||||
relaunched.service.depositDrop(
|
||||
content: "first",
|
||||
messageID: messageID,
|
||||
recipientNoiseKey: firstKey
|
||||
) { relaunchResults.append($0) }
|
||||
relaunched.service.depositDrop(
|
||||
content: "second",
|
||||
messageID: messageID,
|
||||
recipientNoiseKey: secondKey
|
||||
) { relaunchResults.append($0) }
|
||||
relaunched.service.depositDrop(
|
||||
content: "third",
|
||||
messageID: messageID,
|
||||
recipientNoiseKey: thirdKey
|
||||
) { relaunchResults.append($0) }
|
||||
|
||||
#expect(relaunchResults == [false, false, true])
|
||||
#expect(relaunched.sealRequests.count == 1)
|
||||
#expect(relaunched.sealRequests.first?.key == thirdKey)
|
||||
#expect(relaunched.publishedEvents.count == 1)
|
||||
}
|
||||
|
||||
@Test func legacyPublishedMessageIDIsWildcardUntilItsOriginalExpiry() {
|
||||
let fileURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("bridge-dedup-\(UUID().uuidString).json")
|
||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||
var date = Date()
|
||||
let messageID = "legacy-wildcard"
|
||||
let recipientKey = Fixture.randomKey()
|
||||
let store = BridgeDropDedupStore(fileURL: fileURL)
|
||||
store.save(BridgeDropDedupStore.Snapshot(
|
||||
publishedDropKeys: [messageID: date],
|
||||
seenDropEventIDs: [:]
|
||||
))
|
||||
|
||||
let fixture = Fixture(
|
||||
now: { date },
|
||||
dedupStore: BridgeDropDedupStore(fileURL: fileURL)
|
||||
)
|
||||
fixture.sealResult = makeEnvelope(recipientKey: recipientKey)
|
||||
var results: [Bool] = []
|
||||
fixture.service.depositDrop(
|
||||
content: "legacy",
|
||||
messageID: messageID,
|
||||
recipientNoiseKey: recipientKey
|
||||
) { results.append($0) }
|
||||
#expect(results == [false])
|
||||
#expect(fixture.sealRequests.isEmpty)
|
||||
|
||||
date = date.addingTimeInterval(CourierEnvelope.maxLifetimeSeconds + 1)
|
||||
fixture.service.depositDrop(
|
||||
content: "after expiry",
|
||||
messageID: messageID,
|
||||
recipientNoiseKey: recipientKey
|
||||
) { results.append($0) }
|
||||
#expect(results == [false, true])
|
||||
#expect(fixture.publishedEvents.count == 1)
|
||||
fixture.service.flushDedupSnapshot()
|
||||
|
||||
let snapshot = BridgeDropDedupStore(fileURL: fileURL).load()
|
||||
#expect(snapshot.publishedDropKeys[messageID] == nil)
|
||||
#expect(snapshot.publishedDropKeys.count == 1)
|
||||
}
|
||||
|
||||
@Test func panicWipeInvalidatesInFlightPublishCompletion() throws {
|
||||
let fileURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("bridge-dedup-\(UUID().uuidString).json")
|
||||
@@ -297,8 +425,10 @@ struct BridgeCourierServiceTests {
|
||||
#expect(firstResults == [false])
|
||||
|
||||
// The evicted first drop is deposit-able again (slot released).
|
||||
let sealCountBeforeRetry = fixture.sealRequests.count
|
||||
fixture.service.depositDrop(content: "0-retry", messageID: firstID, recipientNoiseKey: key)
|
||||
#expect(fixture.service.pendingDrops.last?.dedupKey == firstID)
|
||||
#expect(fixture.sealRequests.count == sealCountBeforeRetry + 1)
|
||||
#expect(fixture.service.pendingDrops.count == BridgeCourierService.Limits.maxPendingDrops)
|
||||
}
|
||||
|
||||
@Test func oversizeDropConsumesSlotInsteadOfChurning() {
|
||||
|
||||
@@ -79,18 +79,231 @@ struct MessageRouterTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendPrivate_connectedSendIsNotRetained() async {
|
||||
func peerBoundDeliveryAckCannotClearAnotherPeersRetainedMessage() async {
|
||||
let intendedPeer = PeerID(str: "0000000000000023")
|
||||
let otherPeer = PeerID(str: "0000000000000024")
|
||||
let transport = MockTransport()
|
||||
transport.reachablePeers = [intendedPeer, otherPeer]
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
router.sendPrivate(
|
||||
"Secret",
|
||||
to: intendedPeer,
|
||||
recipientNickname: "Intended",
|
||||
messageID: "peer-bound-ack"
|
||||
)
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
|
||||
// Even a receipt arriving over another authenticated conversation
|
||||
// must not terminalize the intended peer's retained retry.
|
||||
router.markDelivered("peer-bound-ack", from: [otherPeer])
|
||||
router.flushOutbox(for: intendedPeer)
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
|
||||
router.markDelivered("peer-bound-ack", from: [intendedPeer])
|
||||
router.flushOutbox(for: intendedPeer)
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendPrivate_connectedSecureSendRetainsUntilDeliveryAck() async {
|
||||
let peerID = PeerID(str: "0000000000000007")
|
||||
let transport = MockTransport()
|
||||
transport.connectedPeers.insert(peerID)
|
||||
transport.reachablePeers.insert(peerID)
|
||||
transport.securePeers = [peerID]
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m7")
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
|
||||
router.flushOutbox(for: peerID)
|
||||
// A newly authenticated/replacement session retries the retained
|
||||
// message instead of losing the first ciphertext to a stale session.
|
||||
router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID])
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
|
||||
router.markDelivered("m7")
|
||||
router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID])
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func authenticationRetry_matchesStableOutboxAliasWithoutDoubleSending() async {
|
||||
let shortPeerID = PeerID(str: "0000000000000019")
|
||||
let stablePeerID = PeerID(hexData: Data(repeating: 0x19, count: 32))
|
||||
let transport = MockTransport()
|
||||
transport.connectedPeers.insert(stablePeerID)
|
||||
transport.securePeers = [stablePeerID]
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
router.sendPrivate("Hello", to: stablePeerID, recipientNickname: "Peer", messageID: "alias-retry")
|
||||
|
||||
router.retrySecurePrivateMessagesAfterAuthentication(for: [shortPeerID, stablePeerID, stablePeerID])
|
||||
|
||||
#expect(transport.sentPrivateMessages.map(\.messageID) == ["alias-retry", "alias-retry"])
|
||||
#expect(transport.sentPrivateMessages.allSatisfy { $0.peerID == stablePeerID })
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func authenticationRetry_preservesFIFOAcrossSplitAliases() async {
|
||||
let shortPeerID = PeerID(str: "0000000000000022")
|
||||
let stablePeerID = PeerID(hexData: Data(repeating: 0x22, count: 32))
|
||||
let transport = MockTransport()
|
||||
transport.connectedPeers = [shortPeerID, stablePeerID]
|
||||
transport.securePeers = [shortPeerID, stablePeerID]
|
||||
let clock = MutableTestClock()
|
||||
let router = MessageRouter(transports: [transport], now: { clock.now })
|
||||
|
||||
// The older message lives under the stable key, even though the auth
|
||||
// callback supplies the ephemeral alias first.
|
||||
router.sendPrivate("Older", to: stablePeerID, recipientNickname: "Peer", messageID: "fifo-old")
|
||||
clock.now = clock.now.addingTimeInterval(1)
|
||||
router.sendPrivate("Newer", to: shortPeerID, recipientNickname: "Peer", messageID: "fifo-new")
|
||||
transport.resetRecordings()
|
||||
|
||||
router.retrySecurePrivateMessagesAfterAuthentication(for: [shortPeerID, stablePeerID])
|
||||
|
||||
#expect(transport.sentPrivateMessages.map(\.messageID) == ["fifo-old", "fifo-new"])
|
||||
#expect(transport.sentPrivateMessages.map(\.peerID) == [stablePeerID, shortPeerID])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func authenticationRetry_doesNotDuplicateNormalPendingHandshakeSend() async {
|
||||
let peerID = PeerID(str: "0000000000000020")
|
||||
let transport = MockTransport()
|
||||
transport.connectedPeers.insert(peerID)
|
||||
transport.securePeers = []
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "normal-handshake")
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
|
||||
// BLE owns this pending send and drains it after authentication. Once
|
||||
// the session becomes secure, the router's targeted auth retry must
|
||||
// stay silent instead of producing a second copy.
|
||||
transport.securePeers = [peerID]
|
||||
router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID])
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
|
||||
router.markDelivered("normal-handshake")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func authenticationRetry_scopesCollidingMessageIDsByPeer() async {
|
||||
let securePeer = PeerID(str: "0000000000000025")
|
||||
let pendingPeer = PeerID(str: "0000000000000026")
|
||||
let transport = MockTransport()
|
||||
transport.connectedPeers = [securePeer, pendingPeer]
|
||||
transport.securePeers = [securePeer]
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
let promotedID = "collision-promoted"
|
||||
let clearedID = "collision-cleared"
|
||||
|
||||
// Pending B then secure A: an ID-global marker falsely promotes B.
|
||||
router.sendPrivate(
|
||||
"pending promoted",
|
||||
to: pendingPeer,
|
||||
recipientNickname: "Pending",
|
||||
messageID: promotedID
|
||||
)
|
||||
router.sendPrivate(
|
||||
"secure promoted",
|
||||
to: securePeer,
|
||||
recipientNickname: "Secure",
|
||||
messageID: promotedID
|
||||
)
|
||||
|
||||
// Secure A then pending B: an ID-global removal falsely clears A.
|
||||
router.sendPrivate(
|
||||
"secure cleared",
|
||||
to: securePeer,
|
||||
recipientNickname: "Secure",
|
||||
messageID: clearedID
|
||||
)
|
||||
router.sendPrivate(
|
||||
"pending cleared",
|
||||
to: pendingPeer,
|
||||
recipientNickname: "Pending",
|
||||
messageID: clearedID
|
||||
)
|
||||
|
||||
transport.resetRecordings()
|
||||
transport.securePeers = [securePeer, pendingPeer]
|
||||
|
||||
router.retrySecurePrivateMessagesAfterAuthentication(for: [pendingPeer])
|
||||
#expect(transport.sentPrivateMessages.isEmpty)
|
||||
|
||||
router.retrySecurePrivateMessagesAfterAuthentication(for: [securePeer])
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
#expect(Set(transport.sentPrivateMessages.map(\.messageID)) == [promotedID, clearedID])
|
||||
#expect(transport.sentPrivateMessages.allSatisfy { $0.peerID == securePeer })
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func authenticationRetry_doesNotDuplicateMessageRequeuedByBLEForHandshake() async {
|
||||
let peerID = PeerID(str: "0000000000000021")
|
||||
let transport = MockTransport()
|
||||
transport.connectedPeers.insert(peerID)
|
||||
transport.securePeers = [peerID]
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "session-lost")
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
|
||||
// The session disappears before a normal outbox flush. That send is
|
||||
// now owned by BLE's pending-handshake queue, so it clears the
|
||||
// router's secure-auth retry marker.
|
||||
transport.securePeers = []
|
||||
router.flushOutbox(for: peerID)
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
|
||||
transport.securePeers = [peerID]
|
||||
router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID])
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
|
||||
router.markDelivered("session-lost")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendPrivate_fastDeliveryAckCannotRaceAheadOfRetention() async {
|
||||
let peerID = PeerID(str: "0000000000000017")
|
||||
let transport = MockTransport()
|
||||
transport.connectedPeers.insert(peerID)
|
||||
transport.securePeers = [peerID]
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
transport.onSendPrivateMessage = { messageID in
|
||||
router.markDelivered(messageID)
|
||||
}
|
||||
|
||||
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "fast-ack")
|
||||
#expect(transport.sentPrivateMessages.map(\.messageID) == ["fast-ack"])
|
||||
|
||||
transport.onSendPrivateMessage = nil
|
||||
router.flushOutbox(for: peerID)
|
||||
#expect(transport.sentPrivateMessages.map(\.messageID) == ["fast-ack"])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func flushOutbox_synchronousAckDoesNotResurrectSnapshotEntry() async {
|
||||
let peerID = PeerID(str: "0000000000000018")
|
||||
let transport = MockTransport()
|
||||
transport.connectedPeers.insert(peerID)
|
||||
transport.securePeers = [peerID]
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "flush-fast-ack")
|
||||
transport.onSendPrivateMessage = { messageID in
|
||||
router.markDelivered(messageID)
|
||||
}
|
||||
|
||||
router.flushOutbox(for: peerID)
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
|
||||
transport.onSendPrivateMessage = nil
|
||||
router.flushOutbox(for: peerID)
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
@@ -392,10 +605,31 @@ struct MessageRouterTests {
|
||||
#expect(transport.sentPrivateMessages.count == 11)
|
||||
}
|
||||
|
||||
/// With an established secure session the connected fast-path stays
|
||||
/// exactly as before: trusted outright, no retained copy, no courier.
|
||||
@Test @MainActor
|
||||
func sendPrivate_connectedWithSecureSessionIsTrustedOutright() async {
|
||||
func authenticationRetry_capsActualSecureTransmissions() async {
|
||||
let peerID = PeerID(str: "00000000000000ad")
|
||||
let transport = MockTransport()
|
||||
transport.connectedPeers.insert(peerID)
|
||||
transport.securePeers = [peerID]
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
var dropped: [String] = []
|
||||
router.onMessageDropped = { messageID, _ in dropped.append(messageID) }
|
||||
|
||||
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "secure-retry")
|
||||
for _ in 0..<10 {
|
||||
router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID])
|
||||
}
|
||||
|
||||
#expect(dropped == ["secure-retry"])
|
||||
#expect(transport.sentPrivateMessages.count == 8)
|
||||
}
|
||||
|
||||
/// With an established secure session the connected fast-path sends
|
||||
/// immediately and never leaks to couriers, but retains a local encrypted
|
||||
/// outbox copy until the peer confirms receipt.
|
||||
@Test @MainActor
|
||||
func sendPrivate_connectedWithSecureSessionRetainsLocallyWithoutCourier() async {
|
||||
let peerID = PeerID(str: "00000000000000ab")
|
||||
let peerKey = Data(repeating: 0xAB, count: 32)
|
||||
let courier = PeerID(str: "00000000000000cc")
|
||||
@@ -415,7 +649,11 @@ struct MessageRouterTests {
|
||||
#expect(transport.sentPrivateMessages.map(\.messageID) == ["cs2"])
|
||||
#expect(transport.sentCourierMessages.isEmpty)
|
||||
router.flushOutbox(for: peerID)
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
#expect(transport.sentCourierMessages.isEmpty)
|
||||
router.markDelivered("cs2")
|
||||
router.flushOutbox(for: peerID)
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
@@ -527,6 +765,52 @@ struct MessageRouterTests {
|
||||
#expect(carried == ["bridge-ack"])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func bridgeDepositsScopeCollidingMessageIDsByRecipient() async {
|
||||
let firstRecipient = PeerID(str: "00000000000000b1")
|
||||
let secondRecipient = PeerID(str: "00000000000000b2")
|
||||
let firstKey = Data(repeating: 0xB1, count: 32)
|
||||
let secondKey = Data(repeating: 0xB2, count: 32)
|
||||
let recipientKeys = [
|
||||
firstRecipient: firstKey,
|
||||
secondRecipient: secondKey
|
||||
]
|
||||
let router = MessageRouter(
|
||||
transports: [MockTransport()],
|
||||
courierDirectory: CourierDirectory(
|
||||
noiseKey: { recipientKeys[$0] },
|
||||
isTrustedCourier: { _ in false }
|
||||
)
|
||||
)
|
||||
var requestedKeys: [Data] = []
|
||||
var completions: [@MainActor (Bool) -> Void] = []
|
||||
router.bridgeCourierDeposit = { _, _, recipientKey, completion in
|
||||
requestedKeys.append(recipientKey)
|
||||
completions.append(completion)
|
||||
}
|
||||
var carriedPeers: [PeerID] = []
|
||||
router.onMessageCarried = { _, peerID in carriedPeers.append(peerID) }
|
||||
|
||||
router.sendPrivate(
|
||||
"First",
|
||||
to: firstRecipient,
|
||||
recipientNickname: "First",
|
||||
messageID: "bridge-collision"
|
||||
)
|
||||
router.sendPrivate(
|
||||
"Second",
|
||||
to: secondRecipient,
|
||||
recipientNickname: "Second",
|
||||
messageID: "bridge-collision"
|
||||
)
|
||||
|
||||
#expect(completions.count == 2)
|
||||
#expect(Set(requestedKeys) == [firstKey, secondKey])
|
||||
|
||||
completions.forEach { $0(true) }
|
||||
#expect(Set(carriedPeers) == [firstRecipient, secondRecipient])
|
||||
}
|
||||
|
||||
// MARK: - Outbox persistence
|
||||
|
||||
@Test @MainActor
|
||||
@@ -643,7 +927,10 @@ struct MessageRouterTests {
|
||||
transport.reachablePeers.formUnion([acknowledgedPeer, otherPeer])
|
||||
let router = MessageRouter(transports: [transport], outboxStore: restoredStore)
|
||||
|
||||
#expect(!router.markDelivered("shared-locked-id", for: [acknowledgedPeer]))
|
||||
router.markDelivered(
|
||||
"shared-locked-id",
|
||||
from: [acknowledgedPeer]
|
||||
)
|
||||
protectedDataUnavailable = false
|
||||
restoredStore.retryDeferredLoad()
|
||||
await Task.yield()
|
||||
@@ -853,12 +1140,14 @@ struct MessageRouterTests {
|
||||
protectedDataUnavailable = false
|
||||
restoredStore.retryDeferredLoad() // captures unseen durable + known wake
|
||||
|
||||
// Secure direct flush removes the wake message before recovery's
|
||||
// MainActor merge. It must remain removed, while the unseen durable
|
||||
// message still arrives through the pending recovery claim.
|
||||
// A secure direct retry followed by its delivery ack removes the wake
|
||||
// message before recovery's MainActor merge. It must remain removed,
|
||||
// while the unseen durable message still arrives through the pending
|
||||
// recovery claim.
|
||||
transport.connectedPeers.insert(peerID)
|
||||
transport.securePeers = [peerID]
|
||||
router.flushOutbox(for: peerID)
|
||||
router.markDelivered("recovery-gap-known")
|
||||
await Task.yield()
|
||||
await Task.yield()
|
||||
|
||||
@@ -929,7 +1218,8 @@ struct MessageRouterTests {
|
||||
restoredStore.retryDeferredLoad() // persists D+W and queues recovery
|
||||
transport.connectedPeers.insert(peerID)
|
||||
transport.securePeers = [peerID]
|
||||
router.flushOutbox(for: peerID) // removes W before queued callback
|
||||
router.flushOutbox(for: peerID)
|
||||
router.markDelivered("recovery-write-failure-known") // removes W before queued callback
|
||||
|
||||
// The gap save may remove W, but it must leave unseen D durable until
|
||||
// MessageRouter receives the pending recovery callback.
|
||||
|
||||
Reference in New Issue
Block a user