mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 21:45:20 +00:00
Fix panic wipe relay and geohash state
This commit is contained in:
@@ -298,6 +298,41 @@ final class NostrRelayManager: ObservableObject {
|
||||
torReadyWaitAttempts = 0
|
||||
updateConnectionStatus()
|
||||
}
|
||||
|
||||
/// Panic wipe reset: close sockets and drop every user/session-specific
|
||||
/// relay intent without invoking old callbacks. Unlike `disconnect()`, this
|
||||
/// must not preserve subscription replay state because geohash DM handlers
|
||||
/// can capture pre-wipe Nostr private keys.
|
||||
func resetForPanicWipe() {
|
||||
connectionGeneration &+= 1
|
||||
for (_, task) in connections {
|
||||
task.cancel(with: .goingAway, reason: nil)
|
||||
}
|
||||
connections.removeAll()
|
||||
subscriptions.removeAll()
|
||||
pendingSubscriptions.removeAll()
|
||||
messageHandlers.removeAll()
|
||||
subscriptionRequestState.removeAll()
|
||||
subscribeCoalesce.removeAll()
|
||||
eoseTrackers.removeAll()
|
||||
pendingEOSECallbacks.removeAll()
|
||||
pendingTorConnectionURLs.removeAll()
|
||||
awaitingTorForConnections = false
|
||||
torReadyWaitAttempts = 0
|
||||
recentInboundEventKeys.removeAll()
|
||||
recentInboundEventKeyOrder.removeAll()
|
||||
duplicateInboundEventDropCount = 0
|
||||
duplicateInboundEventDropCountBySubscription.removeAll()
|
||||
inboundEventLogCount = 0
|
||||
Self.pendingGiftWrapIDs.removeAll()
|
||||
|
||||
messageQueueLock.lock()
|
||||
messageQueue.removeAll()
|
||||
pendingSendDropCount = 0
|
||||
messageQueueLock.unlock()
|
||||
|
||||
updateConnectionStatus()
|
||||
}
|
||||
|
||||
/// Ensure connections exist to the given relay URLs (idempotent).
|
||||
func ensureConnections(to relayUrls: [String]) {
|
||||
@@ -1170,6 +1205,18 @@ final class NostrRelayManager: ObservableObject {
|
||||
return Set(map.keys)
|
||||
}
|
||||
|
||||
var debugMessageHandlerCount: Int {
|
||||
messageHandlers.count
|
||||
}
|
||||
|
||||
var debugSubscriptionRequestCount: Int {
|
||||
subscriptionRequestState.count
|
||||
}
|
||||
|
||||
var debugPendingEOSECallbackCount: Int {
|
||||
pendingEOSECallbacks.count
|
||||
}
|
||||
|
||||
var debugDuplicateInboundEventDropCount: Int {
|
||||
duplicateInboundEventDropCount
|
||||
}
|
||||
|
||||
@@ -1158,13 +1158,25 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
// Clear selected private chat
|
||||
selectedPrivateChatPeer = nil
|
||||
|
||||
// Clear live location/geohash session state. Persisted location state
|
||||
// was wiped above, but the running view model can still be scoped to a
|
||||
// geohash channel and hold subscriptions tied to the old Nostr identity.
|
||||
activeChannel = .mesh
|
||||
setGeoChatSubscriptionID(nil)
|
||||
setGeoDmSubscriptionID(nil)
|
||||
_ = clearGeoSamplingSubs()
|
||||
cachedGeohashIdentity = nil
|
||||
nostrKeyMapping.removeAll()
|
||||
|
||||
// Clear read receipt tracking
|
||||
sentReadReceipts.removeAll()
|
||||
deduplicationService.clearAll()
|
||||
|
||||
// IMPORTANT: Clear Nostr-related state
|
||||
// Disconnect from Nostr relays and clear subscriptions
|
||||
nostrRelayManager?.disconnect()
|
||||
// Drop relay subscriptions, handlers, pending sends, and replay state.
|
||||
// Geohash DM handlers can capture pre-wipe Nostr identities, so a plain
|
||||
// disconnect is not enough here.
|
||||
NostrRelayManager.shared.resetForPanicWipe()
|
||||
nostrRelayManager = nil
|
||||
|
||||
// Clear Nostr identity associations
|
||||
|
||||
@@ -1042,6 +1042,38 @@ struct ChatViewModelPanicTests {
|
||||
#expect(viewModel.unreadPrivateMessages.isEmpty)
|
||||
#expect(viewModel.selectedPrivateChatPeer == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func panicClearAllData_resetsLiveGeohashAndNostrState() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruy"
|
||||
let channel = GeohashChannel(level: .city, geohash: geohash)
|
||||
let identity = try NostrIdentity.generate()
|
||||
let pubkey = String(repeating: "ab", count: 32)
|
||||
let peerID = PeerID(nostr: pubkey)
|
||||
|
||||
viewModel.activeChannel = .location(channel)
|
||||
viewModel.setGeoChatSubscriptionID("geo-\(geohash)")
|
||||
viewModel.setGeoDmSubscriptionID("geo-dm-\(geohash)")
|
||||
viewModel.addGeoSamplingSub("geo-sample-\(geohash)", forGeohash: geohash)
|
||||
viewModel.cachedGeohashIdentity = (geohash, identity)
|
||||
viewModel.registerNostrKeyMapping(pubkey, for: peerID)
|
||||
viewModel.currentGeohash = geohash
|
||||
viewModel.geoNicknames = [pubkey: "alice"]
|
||||
viewModel.teleportedGeo = [pubkey]
|
||||
|
||||
viewModel.panicClearAllData()
|
||||
|
||||
#expect(viewModel.activeChannel == .mesh)
|
||||
#expect(viewModel.geoSubscriptionID == nil)
|
||||
#expect(viewModel.geoDmSubscriptionID == nil)
|
||||
#expect(viewModel.geoSamplingSubs.isEmpty)
|
||||
#expect(viewModel.cachedGeohashIdentity == nil)
|
||||
#expect(viewModel.nostrKeyMapping.isEmpty)
|
||||
#expect(viewModel.currentGeohash == nil)
|
||||
#expect(viewModel.geoNicknames.isEmpty)
|
||||
#expect(viewModel.teleportedGeo.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Service Lifecycle Tests
|
||||
|
||||
@@ -1328,6 +1328,49 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 0)
|
||||
}
|
||||
|
||||
func test_resetForPanicWipe_dropsSessionRelayStateWithoutFiringCallbacks() async throws {
|
||||
let relayURL = "wss://panic-reset.example"
|
||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||
let event = try makeSignedEvent(content: "queued before panic")
|
||||
var handledEvents = 0
|
||||
var eoseCount = 0
|
||||
|
||||
context.manager.subscribe(
|
||||
filter: makeFilter(),
|
||||
id: "panic-sub",
|
||||
relayUrls: [relayURL],
|
||||
handler: { _ in handledEvents += 1 },
|
||||
onEOSE: { eoseCount += 1 }
|
||||
)
|
||||
context.manager.sendEvent(event, to: [relayURL])
|
||||
|
||||
XCTAssertEqual(context.manager.debugMessageHandlerCount, 1)
|
||||
XCTAssertEqual(context.manager.debugSubscriptionRequestCount, 1)
|
||||
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 1)
|
||||
XCTAssertEqual(context.manager.debugPendingEOSECallbackCount, 1)
|
||||
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 1)
|
||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||
|
||||
context.manager.resetForPanicWipe()
|
||||
|
||||
XCTAssertEqual(context.manager.debugMessageHandlerCount, 0)
|
||||
XCTAssertEqual(context.manager.debugSubscriptionRequestCount, 0)
|
||||
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 0)
|
||||
XCTAssertEqual(context.manager.debugPendingEOSECallbackCount, 0)
|
||||
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 0)
|
||||
XCTAssertEqual(handledEvents, 0)
|
||||
XCTAssertEqual(eoseCount, 0)
|
||||
|
||||
// Stale Tor wait and fallback callbacks from the pre-wipe generation
|
||||
// must not resurrect connections or settle callbacks after reset.
|
||||
context.torWaiter.resolve(true)
|
||||
context.scheduler.runNext()
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
|
||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||
XCTAssertEqual(eoseCount, 0)
|
||||
}
|
||||
|
||||
func test_reconnectBackoff_appliesJitterWithinConfiguredBounds() async {
|
||||
let relayURL = "wss://jitter-bounds.example"
|
||||
// Pin the jitter source to the extremes and the midpoint of [0, 1).
|
||||
|
||||
Reference in New Issue
Block a user