From 9df056d6a8f260a63fe35b02ac59623057313f03 Mon Sep 17 00:00:00 2001 From: jack Date: Sun, 26 Jul 2026 13:15:41 +0200 Subject: [PATCH] Reset BLEService's own receipt cache on panic completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panic media wipe runs on the file store PanicRecoveryOperations .live() constructs, but receipt lookups are served by BLEService's own incomingFileStore, whose in-memory index survived the wipe. Drop that instance's cache in completePanicReset — after media deletion, before radio admission reopens — so the next lookup rebuilds from the wiped directory. The panic test now drives the production suspend/wipe/ complete sequence against the instance BLEService actually uses. Co-Authored-By: Claude Opus 4.8 --- .../Services/BLE/BLEIncomingFileStore.swift | 11 +++++++ bitchat/Services/BLE/BLEService.swift | 15 +++++++++ .../BLEFileTransferHandlerTests.swift | 32 ++++++++++++++++--- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/bitchat/Services/BLE/BLEIncomingFileStore.swift b/bitchat/Services/BLE/BLEIncomingFileStore.swift index a16efa87..9463c739 100644 --- a/bitchat/Services/BLE/BLEIncomingFileStore.swift +++ b/bitchat/Services/BLE/BLEIncomingFileStore.swift @@ -268,6 +268,17 @@ struct BLEIncomingFileStore { } } + /// Drops THIS instance's in-memory receipt index after a panic wipe. + /// + /// `panicWipe` already resets the receipt store it runs on, but the + /// production wipe runs on the `PanicRecoveryOperations.live()` file + /// store while receipt lookups are served by `BLEService`'s own + /// `incomingFileStore`. The service's panic path must invalidate its own + /// cache explicitly or pre-panic decisions survive in memory. + func resetPrivateMediaReceiptsForPanic() { + privateMediaReceipts.resetForPanic() + } + func privateMediaReceiptState( messageID: String ) -> BLEPrivateMediaReceiptState { diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 67f7eb05..265a4cc1 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -739,6 +739,12 @@ final class BLEService: NSObject { /// Reopen the radio only after media deletion and recovery-marker commit. func completePanicReset(restartServices: Bool) { + // The media wipe ran on the recovery operations' own file store; this + // service's store still caches pre-panic receipt decisions (and a + // callback drained during suspension may have re-read the pre-wipe + // ledger). Drop the cache before admission reopens so the next lookup + // rebuilds from the wiped directory. + incomingFileStore.resetPrivateMediaReceiptsForPanic() setPanicSuspended(false) guard restartServices else { return } startServices() @@ -3413,6 +3419,15 @@ extension BLEService { capturePanicLifecycleGeneration() != nil } + /// Queries the receipt store of the service's OWN incoming-file store — + /// the instance production lookups run against — so panic tests exercise + /// the real wiring instead of a same-instance shortcut. + func _test_privateMediaReceiptState( + messageID: String + ) -> BLEPrivateMediaReceiptState { + incomingFileStore.privateMediaReceiptState(messageID: messageID) + } + /// Models a CoreBluetooth delegate callback without requiring a physical /// peripheral. The callback itself runs on `bleQueue`, exactly where the /// panic radio-stop barrier must linearize it. diff --git a/bitchatTests/Services/BLEFileTransferHandlerTests.swift b/bitchatTests/Services/BLEFileTransferHandlerTests.swift index 618aa070..41f242e8 100644 --- a/bitchatTests/Services/BLEFileTransferHandlerTests.swift +++ b/bitchatTests/Services/BLEFileTransferHandlerTests.swift @@ -882,16 +882,40 @@ struct BLEFileTransferHandlerTests { let seed = BLEPrivateMediaReceiptStore(baseDirectory: base) #expect(seed.recordDeleted(messageID: messageID)) - let store = BLEIncomingFileStore(baseDirectory: base) + // Production wiring: receipt lookups run against the service's OWN + // incoming-file store while the panic wipe runs on the separate store + // `PanicRecoveryOperations.live()` constructs. The test must reset + // the instance BLEService uses, not a same-instance shortcut. + let keychain = MockKeychain() + let identityManager = MockIdentityManager(keychain) + let service = BLEService( + keychain: keychain, + idBridge: NostrIdentityBridge(keychain: MockKeychainHelper()), + identityManager: identityManager, + initializeBluetoothManagers: false, + incomingFileStore: BLEIncomingFileStore(baseDirectory: base) + ) #expect( - store.privateMediaReceiptState(messageID: messageID) + service._test_privateMediaReceiptState(messageID: messageID) == .tombstoned ) - try store.panicWipe() + service.suspendForPanicReset() + // A receive callback drained during suspension can still consult the + // ledger and re-cache the pre-wipe decision before media deletion. + #expect( + service._test_privateMediaReceiptState(messageID: messageID) + == .tombstoned + ) + + // The wipe itself runs on the recovery operations' distinct store, + // exactly like ChatViewModel's panic transaction. + let recoveryStore = BLEIncomingFileStore(baseDirectory: base) + try recoveryStore.panicWipe() + service.completePanicReset(restartServices: false) #expect( - store.privateMediaReceiptState(messageID: messageID) + service._test_privateMediaReceiptState(messageID: messageID) == .absent ) }