Fix panic wipe relay and geohash state

This commit is contained in:
jack
2026-06-16 13:56:15 +02:00
parent cd7ffa0df9
commit 914135adb0
4 changed files with 136 additions and 2 deletions
+47
View File
@@ -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
}
+14 -2
View File
@@ -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
+32
View File
@@ -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).