From 1bacc2e6bf5a7efac8efec7e3b81ce7956abe0dd Mon Sep 17 00:00:00 2001 From: jack Date: Sat, 25 Jul 2026 20:41:31 +0200 Subject: [PATCH] Invalidate media deletion callbacks during panic --- .../Services/BLE/BLEIncomingFileStore.swift | 19 ++++-- bitchat/Services/BLE/BLEService.swift | 31 +++++++++- .../ChatMediaTransferCoordinator.swift | 8 ++- bitchat/ViewModels/ChatViewModel.swift | 24 ++++++- bitchatTests/BLEServiceCoreTests.swift | 38 ++++++++++++ ...MediaTransferCoordinatorContextTests.swift | 18 ++++++ bitchatTests/ChatViewModelTests.swift | 62 +++++++++++++++++++ .../BLEFileTransferHandlerTests.swift | 46 ++++++++++++++ 8 files changed, 236 insertions(+), 10 deletions(-) diff --git a/bitchat/Services/BLE/BLEIncomingFileStore.swift b/bitchat/Services/BLE/BLEIncomingFileStore.swift index 5e89393c..03530e53 100644 --- a/bitchat/Services/BLE/BLEIncomingFileStore.swift +++ b/bitchat/Services/BLE/BLEIncomingFileStore.swift @@ -179,10 +179,21 @@ struct BLEIncomingFileStore: @unchecked Sendable { func panicWipe( hasDurablePendingMarker: Bool = false ) throws { - // The receipt index caches tombstones as well as accepted payloads. - // Always invalidate it on return, including partial-failure paths, so - // no pre-panic receiver decision survives after identity reset. - defer { privateMediaReceipts.resetForPanic() } + // The receipt index caches tombstones as well as accepted payloads, + // while payload coordination retains save/delete reservations. Always + // invalidate both on return, including partial-failure paths, so no + // pre-panic receiver decision survives after identity reset. + defer { + privateMediaReceipts.resetForPanic() + payloadCoordination.lock.lock() + payloadCoordination.pendingDeliveryPaths.removeAll( + keepingCapacity: false + ) + payloadCoordination.deletionReservations.removeAll( + keepingCapacity: false + ) + payloadCoordination.lock.unlock() + } let markerError: Error? do { diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 0dd87400..96dbeee3 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -3492,6 +3492,18 @@ extension BLEService { } } + func _test_emitTransportEvent( + _ event: TransportEvent, + completion: @escaping () -> Void, + finalization: @escaping (TransportEventDeliveryOutcome) -> Void + ) { + emitTransportEvent( + event, + completion: completion, + finalization: finalization + ) + } + func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true, signingPublicKey: Data? = nil) { if preseedPeer { // Ensure the synthetic peer is known and marked verified for public-message tests @@ -4504,11 +4516,24 @@ extension BLEService { completion: (() -> Void)? = nil, finalization: ((TransportEventDeliveryOutcome) -> Void)? = nil ) { - notifyUI { [weak self] in + guard let generation = capturePanicLifecycleGeneration() else { + Task { @MainActor in + finalization?(.rejected) + } + return + } + Task { @MainActor [weak self] in + guard let self, + self.isCurrentPanicLifecycleGeneration(generation) else { + finalization?(.rejected) + return + } TransportEventDeliveryGate.attempt( - shouldDeliver: { shouldDeliver?() ?? true }, + shouldDeliver: { + self.isCurrentPanicLifecycleGeneration(generation) + && (shouldDeliver?() ?? true) + }, deliver: { - guard let self else { return .rejected } return self.deliverTransportEvent(event) }, completion: { completion?() }, diff --git a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift index 915dc755..f20f3440 100644 --- a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift +++ b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift @@ -415,6 +415,7 @@ final class ChatMediaTransferCoordinator { private(set) var transferIdToMessageIDs: [String: [String]] = [:] private(set) var messageIDToTransferId: [String: String] = [:] + private var deletionGeneration: UInt64 = 0 private var reconnectRetryRecords: [ String: PrivateMediaReconnectRetryRecord ] = [:] @@ -1099,10 +1100,14 @@ final class ChatMediaTransferCoordinator { return } + let generation = deletionGeneration context.persistDeletedPrivateMedia( messageIDs: [messageID] ) { [weak self] persisted in - guard let self else { return } + guard let self, + self.deletionGeneration == generation else { + return + } guard persisted else { SecureLogger.error( "Refusing to delete private media without a durable tombstone id=\(messageID.prefix(12))…", @@ -1191,6 +1196,7 @@ final class ChatMediaTransferCoordinator { /// is the last filesystem mutation before the transaction can complete. func resetForPanic() { imagePreparationBarrier.invalidateAndWait() + deletionGeneration &+= 1 peersResolvingReconnectRetry.removeAll(keepingCapacity: false) for task in reconnectRetryExpiryTasks.values { task.cancel() diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 6f3022d9..f88beeb6 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -390,6 +390,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage PendingPrivateChatClear ] = [] @MainActor private var privateChatClearInFlight = false + @MainActor private var privateChatClearGeneration: UInt64 = 0 private var pendingLegacyPrivateMediaConsents: [PendingLegacyPrivateMediaConsent] = [] private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) { @@ -718,8 +719,15 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage } privateChatClearInFlight = true let request = queuedPrivateChatClears.removeFirst() - performPrivateChatClear(request) { [weak self] in - guard let self else { return } + let generation = privateChatClearGeneration + performPrivateChatClear( + request, + generation: generation + ) { [weak self] in + guard let self, + self.privateChatClearGeneration == generation else { + return + } self.privateChatClearInFlight = false self.startNextPrivateChatClearIfNeeded() } @@ -728,8 +736,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage @MainActor private func performPrivateChatClear( _ request: PendingPrivateChatClear, + generation: UInt64, completion: @escaping @MainActor () -> Void ) { + guard privateChatClearGeneration == generation else { + completion() + return + } let peerID = request.peerID let selectedConversationID = request.sourceConversationID let messagesToClear = request.messages @@ -816,6 +829,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage persisted: Bool, durableStableIDs: Set ) { + guard privateChatClearGeneration == generation else { + completion() + return + } guard persisted else { SecureLogger.error( "Refusing to clear private chat without durable media tombstones peer=\(peerID.id.prefix(8))…", @@ -1537,6 +1554,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage // handles before clearing state or removing the media directory. mediaTransferCoordinator.resetForPanic() liveVoiceCoordinator.resetForPanic() + privateChatClearGeneration &+= 1 + queuedPrivateChatClears.removeAll(keepingCapacity: false) + privateChatClearInFlight = false // Deny and release any clear-media confirmations before identities, // message state, and local files are wiped. diff --git a/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift index fb8676cd..733695e3 100644 --- a/bitchatTests/BLEServiceCoreTests.swift +++ b/bitchatTests/BLEServiceCoreTests.swift @@ -1030,6 +1030,44 @@ struct BLEServiceCoreTests { )) } + @Test @MainActor + func panicSuspension_finalizesStaleTransportEventsAsRejected() async { + let ble = makeService() + let delegate = TransportEventCaptureDelegate() + ble.eventDelegate = delegate + let message = BitchatMessage( + id: "pre-panic-finalization", + sender: "Peer", + content: "must be rejected", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Me", + senderPeerID: PeerID(str: "1122334455667788") + ) + var completions = 0 + var outcomes: [TransportEventDeliveryOutcome] = [] + + ble._test_emitTransportEvent( + .messageReceived(message), + completion: { completions += 1 }, + finalization: { outcomes.append($0) } + ) + ble.suspendForPanicReset() + ble._test_emitTransportEvent( + .messageReceived(message), + completion: { completions += 1 }, + finalization: { outcomes.append($0) } + ) + for _ in 0..<4 { + await Task.yield() + } + + #expect(delegate.messageIDs.isEmpty) + #expect(completions == 0) + #expect(outcomes == [.rejected, .rejected]) + } + @Test func modifiedServices_rediscoverWhenBitChatServiceIsInvalidated() async throws { let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB") diff --git a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift index 4ebc5e41..e2a9a523 100644 --- a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift +++ b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift @@ -607,6 +607,24 @@ struct ChatMediaTransferCoordinatorContextTests { #expect(coordinator.messageIDToTransferId[messageID] == nil) } + @Test @MainActor + func deleteIncomingStableMediaCompletionAfterPanicIsIgnored() { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + let messageID = "media-11223344556677889900aabbccddeeff" + context.requiredTombstoneIDs = [messageID] + context.deferDeletedMediaPersistence = true + + coordinator.deleteMediaMessage(messageID: messageID) + #expect(context.persistedDeletionBatches == [[messageID]]) + + coordinator.resetForPanic() + context.resolveNextDeletionPersistence(true) + + #expect(context.removedMessages.isEmpty) + #expect(context.untombstonedMediaRemovals.isEmpty) + } + @Test @MainActor func deleteIncomingStableMediaPreservesStateWhenTombstoneFails() { let context = MockChatMediaTransferContext() diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index 35fce3d3..477fcdc9 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -1865,6 +1865,68 @@ struct ChatViewModelPrivateMediaDeletionTests { #expect((viewModel.privateChats[aliasPeerID] ?? []).isEmpty) } + @Test @MainActor + func panicInvalidatesActiveAndQueuedPrivateChatClears() { + let (viewModel, transport) = makeTestableViewModel() + transport.deferDeletedPrivateMediaPersistence = true + let firstPeerID = PeerID(str: String(repeating: "4", count: 64)) + let secondPeerID = PeerID(str: String(repeating: "5", count: 64)) + let firstID = "media-\(String(repeating: "6", count: 32))" + let secondID = "media-\(String(repeating: "7", count: 32))" + viewModel.seedPrivateChat([ + privateMediaMessage( + id: firstID, + sender: "First", + senderPeerID: firstPeerID, + recipient: viewModel.nickname, + filename: "first-pre-panic.jpg" + ) + ], for: firstPeerID) + viewModel.seedPrivateChat([ + privateMediaMessage( + id: secondID, + sender: "Second", + senderPeerID: secondPeerID, + recipient: viewModel.nickname, + filename: "second-pre-panic.jpg" + ) + ], for: secondPeerID) + + viewModel.clearPrivateChat(firstPeerID) + viewModel.clearPrivateChat(secondPeerID) + #expect( + transport.deletedPrivateMediaMessageIDBatches == [[firstID]] + ) + + _ = viewModel.panicClearAllData(restartServices: false) + viewModel.seedPrivateChat([ + privateMediaMessage( + id: firstID, + sender: "First", + senderPeerID: firstPeerID, + recipient: viewModel.nickname, + filename: "first-post-panic.jpg" + ) + ], for: firstPeerID) + viewModel.seedPrivateChat([ + privateMediaMessage( + id: secondID, + sender: "Second", + senderPeerID: secondPeerID, + recipient: viewModel.nickname, + filename: "second-post-panic.jpg" + ) + ], for: secondPeerID) + + transport.resolveNextDeletedPrivateMediaPersistence(true) + + #expect( + transport.deletedPrivateMediaMessageIDBatches == [[firstID]] + ) + #expect(viewModel.privateChats[firstPeerID]?.map(\.id) == [firstID]) + #expect(viewModel.privateChats[secondPeerID]?.map(\.id) == [secondID]) + } + private func privateMediaMessage( id: String, sender: String, diff --git a/bitchatTests/Services/BLEFileTransferHandlerTests.swift b/bitchatTests/Services/BLEFileTransferHandlerTests.swift index b17aafd2..c6478287 100644 --- a/bitchatTests/Services/BLEFileTransferHandlerTests.swift +++ b/bitchatTests/Services/BLEFileTransferHandlerTests.swift @@ -1122,6 +1122,52 @@ struct BLEFileTransferHandlerTests { ) } + @Test + func panicWipeInvalidatesPayloadCoordinationReservations() throws { + let base = FileManager.default.temporaryDirectory + .appendingPathComponent( + "panic-payload-coordination-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: base) } + let store = BLEIncomingFileStore(baseDirectory: base) + let pendingName = "pending-before-panic.jpg" + let pendingURL = try #require(store.save( + data: Data("old".utf8), + preferredName: pendingName, + subdirectory: "images/incoming", + fallbackExtension: "jpg", + defaultPrefix: "image" + )) + let messageID = "media-aabbccddeeff00112233445566778899" + let reservation = try #require(store.reservePrivateMediaDeletion( + messageIDs: [messageID], + payloadRelativePaths: [ + messageID: "images/incoming/delete-before-panic.jpg" + ] + )) + + try store.panicWipe() + + #expect(!store.commitPrivateMediaDeletion( + reservation: reservation, + messageIDs: [messageID], + payloadRelativePaths: [ + messageID: "images/incoming/delete-before-panic.jpg" + ], + protectedPayloadRelativePaths: [] + )) + let postPanicURL = try #require(store.save( + data: Data("new".utf8), + preferredName: pendingName, + subdirectory: "images/incoming", + fallbackExtension: "jpg", + defaultPrefix: "image" + )) + #expect(pendingURL.lastPathComponent == pendingName) + #expect(postPanicURL.lastPathComponent == pendingName) + } + @Test func panicWipeAttemptsDeletionWhenMarkerPersistenceFails() throws { enum MarkerFailure: Error { case unavailable }