Invalidate media deletion callbacks during panic

This commit is contained in:
jack
2026-07-25 23:49:41 +02:00
parent 1a56bec30d
commit 2a72ea911f
8 changed files with 236 additions and 10 deletions
@@ -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 {
+28 -3
View File
@@ -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?() },
@@ -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()
+22 -2
View File
@@ -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<String>
) {
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.
+38
View File
@@ -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")
@@ -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()
+62
View File
@@ -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,
@@ -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 }