Reset BLEService's own receipt cache on panic completion

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 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-26 13:15:41 +02:00
co-authored by Claude Opus 4.8
parent 52709addc1
commit 9df056d6a8
3 changed files with 54 additions and 4 deletions
@@ -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 {
+15
View File
@@ -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.
@@ -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
)
}