mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 01:05:19 +00:00
Fix panic wipe relay and geohash state
This commit is contained in:
@@ -299,6 +299,41 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
updateConnectionStatus()
|
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).
|
/// Ensure connections exist to the given relay URLs (idempotent).
|
||||||
func ensureConnections(to relayUrls: [String]) {
|
func ensureConnections(to relayUrls: [String]) {
|
||||||
// Global network policy gate
|
// Global network policy gate
|
||||||
@@ -1170,6 +1205,18 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
return Set(map.keys)
|
return Set(map.keys)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var debugMessageHandlerCount: Int {
|
||||||
|
messageHandlers.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var debugSubscriptionRequestCount: Int {
|
||||||
|
subscriptionRequestState.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var debugPendingEOSECallbackCount: Int {
|
||||||
|
pendingEOSECallbacks.count
|
||||||
|
}
|
||||||
|
|
||||||
var debugDuplicateInboundEventDropCount: Int {
|
var debugDuplicateInboundEventDropCount: Int {
|
||||||
duplicateInboundEventDropCount
|
duplicateInboundEventDropCount
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1158,13 +1158,25 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
// Clear selected private chat
|
// Clear selected private chat
|
||||||
selectedPrivateChatPeer = nil
|
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
|
// Clear read receipt tracking
|
||||||
sentReadReceipts.removeAll()
|
sentReadReceipts.removeAll()
|
||||||
deduplicationService.clearAll()
|
deduplicationService.clearAll()
|
||||||
|
|
||||||
// IMPORTANT: Clear Nostr-related state
|
// IMPORTANT: Clear Nostr-related state
|
||||||
// Disconnect from Nostr relays and clear subscriptions
|
// Drop relay subscriptions, handlers, pending sends, and replay state.
|
||||||
nostrRelayManager?.disconnect()
|
// Geohash DM handlers can capture pre-wipe Nostr identities, so a plain
|
||||||
|
// disconnect is not enough here.
|
||||||
|
NostrRelayManager.shared.resetForPanicWipe()
|
||||||
nostrRelayManager = nil
|
nostrRelayManager = nil
|
||||||
|
|
||||||
// Clear Nostr identity associations
|
// Clear Nostr identity associations
|
||||||
|
|||||||
@@ -1042,6 +1042,38 @@ struct ChatViewModelPanicTests {
|
|||||||
#expect(viewModel.unreadPrivateMessages.isEmpty)
|
#expect(viewModel.unreadPrivateMessages.isEmpty)
|
||||||
#expect(viewModel.selectedPrivateChatPeer == nil)
|
#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
|
// MARK: - Service Lifecycle Tests
|
||||||
|
|||||||
@@ -1328,6 +1328,49 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 0)
|
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 {
|
func test_reconnectBackoff_appliesJitterWithinConfiguredBounds() async {
|
||||||
let relayURL = "wss://jitter-bounds.example"
|
let relayURL = "wss://jitter-bounds.example"
|
||||||
// Pin the jitter source to the extremes and the midpoint of [0, 1).
|
// Pin the jitter source to the extremes and the midpoint of [0, 1).
|
||||||
|
|||||||
Reference in New Issue
Block a user