From 11570239757b4b531836be4320111c8cdb5c42bf Mon Sep 17 00:00:00 2001 From: jack Date: Sun, 26 Jul 2026 13:24:43 +0200 Subject: [PATCH] Clear pending retry resolution on peer disconnect A private-media policy resolution whose completion is dropped by transport teardown (BLEService messageQueue deallocation guard) left its peersResolvingReconnectRetry entry in place, parking every future reconnect-retry resolution for that peer until panic reset. Disconnect now removes the entry; retained records survive and the next reconnect starts a fresh resolution. Co-Authored-By: Claude Opus 4.8 --- .../ChatMediaTransferCoordinator.swift | 10 ++++ bitchat/ViewModels/ChatViewModel.swift | 4 ++ ...MediaTransferCoordinatorContextTests.swift | 57 +++++++++++++++++++ 3 files changed, 71 insertions(+) diff --git a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift index ea209af4..cc1840b3 100644 --- a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift +++ b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift @@ -965,6 +965,16 @@ final class ChatMediaTransferCoordinator { ) } + /// A policy resolution's completion can be dropped entirely when the + /// transport tears down mid-flight (BLEService's queue guards on a + /// deallocated self), which would leave the pending entry blocking every + /// future resolution for this peer. Disconnection invalidates the + /// resolution's premise anyway, so drop it; retained records stay and the + /// next reconnect starts a fresh resolution. + func peerDidDisconnect(_ peerID: PeerID) { + peersResolvingReconnectRetry.removeValue(forKey: peerID.toShort()) + } + /// Local fragment completion is not proof that the recipient reconstructed /// the file. Only a remote delivery/read receipt releases retry ownership. func confirmPrivateMediaDelivery(messageID: String) { diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index da8cdb71..85d4e765 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1773,6 +1773,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage case .peerDisconnected(let peerID): transportEventCoordinator.didDisconnectFromPeerSynchronously(peerID) + mediaTransferCoordinator.peerDidDisconnect(peerID) case .peerListUpdated(let peers): peerListCoordinator.didUpdatePeerListSynchronously(peers) @@ -1866,6 +1867,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage func didDisconnectFromPeer(_ peerID: PeerID) { transportEventCoordinator.didDisconnectFromPeer(peerID) + Task { @MainActor [weak self] in + self?.mediaTransferCoordinator.peerDidDisconnect(peerID) + } } func didUpdatePeerList(_ peers: [PeerID]) { diff --git a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift index f0b88dd0..42684c2d 100644 --- a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift +++ b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift @@ -979,6 +979,63 @@ struct ChatMediaTransferCoordinatorContextTests { #expect(coordinator.messageIDToTransferId[messageID] == nil) } + @Test @MainActor + func disconnectClearsDroppedPolicyResolutionSoRetriesRecover() async throws { + let context = MockChatMediaTransferContext() + let peerID = PeerID(str: "1122334455667788") + context.selectedPrivateChatPeer = peerID + context.supportsAuthenticatedPrivateMediaReceipts = true + let fileName = "voice_3333444455556666.m4a" + let preparer = StaticVoiceNotePreparer(fileName: fileName) + let coordinator = ChatMediaTransferCoordinator( + context: context, + prepareVoiceNotePacket: { url in + try preparer.prepare(url) + } + ) + let url = try makeCoordinatorVoiceURL(fileName: fileName) + defer { + try? FileManager.default.removeItem( + at: url.deletingLastPathComponent() + ) + } + + coordinator.sendVoiceNote(at: url) + #expect(await TestHelpers.waitUntil( + { context.privateFileSends.count == 1 }, + timeout: TestConstants.longTimeout + )) + let initial = try #require(context.privateFileSends.first) + coordinator.handleTransferEvent(.completed( + id: initial.transferId, + totalFragments: 1 + )) + #expect(coordinator.retainedReconnectRetryCount == 1) + + // The transport accepts this resolution but its completion is never + // invoked (BLEService queue teardown drops the closure), so the + // pending entry parks every subsequent reconnect resolution. + context.resolvesPrivateMediaPolicyImmediately = false + coordinator.peerDidReconnect(peerID) + #expect(context.privateMediaPolicyResolutionRequests.count == 1) + coordinator.peerDidReconnect(peerID) + #expect(context.privateMediaPolicyResolutionRequests.count == 1) + + // Disconnection invalidates the dropped resolution; the next + // reconnect must start fresh and complete the retry. + coordinator.peerDidDisconnect(peerID) + context.resolvesPrivateMediaPolicyImmediately = true + coordinator.peerDidReconnect(peerID) + #expect(context.privateMediaPolicyResolutionRequests.count == 2) + #expect(context.privateFileSends.count == 2) + let retry = try #require(context.privateFileSends.last) + #expect(retry.packet.encode() == initial.packet.encode()) + #expect(retry.peerID == peerID) + #expect(context.privateFileReceiptRetryTransferIDs == [ + retry.transferId + ]) + } + @Test @MainActor func bit8OnlyEncryptedMediaNeverRetainsOrAutomaticallyRetries() async throws { let context = MockChatMediaTransferContext()