diff --git a/bitchat/Services/GeohashChatActivityTracker.swift b/bitchat/Services/GeohashChatActivityTracker.swift index def9d59f..c3d3cdcd 100644 --- a/bitchat/Services/GeohashChatActivityTracker.swift +++ b/bitchat/Services/GeohashChatActivityTracker.swift @@ -41,6 +41,12 @@ final class GeohashChatActivityTracker: ObservableObject { private var lastMessages: [String: GeohashChatPreview] = [:] private let now: () -> Date + #if DEBUG + /// Number of distinct geohashes currently held in memory; exposed only + /// for regression tests verifying stale entries get evicted. + var _trackedGeohashCountForTesting: Int { messageTimes.count } + #endif + init( window: TimeInterval = TransportConfig.uiGeohashChatActivityWindowSeconds, now: @escaping () -> Date = { Date() } @@ -68,6 +74,15 @@ final class GeohashChatActivityTracker: ObservableObject { } else { lastMessages[gh] = GeohashChatPreview(senderName: senderName, content: content, timestamp: clamped) } + + // A geohash's timestamp array only ever gets pruned down to empty — + // its dictionary entry (and the paired lastMessages entry) is never + // dropped, so roaming across many regions over a long session leaks + // one entry per distinct geohash ever observed. Sweep other geohashes + // here so activity that has fully aged out gets evicted instead of + // sitting in memory for the rest of the process's lifetime. + pruneStaleGeohashes(except: gh) + objectWillChange.send() } @@ -113,4 +128,15 @@ final class GeohashChatActivityTracker: ObservableObject { let cutoff = now().addingTimeInterval(-window) return times.filter { $0 >= cutoff } } + + /// Drops any tracked geohash (other than `keep`) whose messages have all + /// aged out of the window, so the maps stay bounded to recently-active + /// geohashes instead of growing for the life of the process. + private func pruneStaleGeohashes(except keep: String) { + for gh in Array(messageTimes.keys) where gh != keep { + guard prune(messageTimes[gh] ?? []).isEmpty else { continue } + messageTimes.removeValue(forKey: gh) + lastMessages.removeValue(forKey: gh) + } + } } diff --git a/bitchatTests/Services/GeohashChatActivityTrackerTests.swift b/bitchatTests/Services/GeohashChatActivityTrackerTests.swift index a9786b14..b9fe7570 100644 --- a/bitchatTests/Services/GeohashChatActivityTrackerTests.swift +++ b/bitchatTests/Services/GeohashChatActivityTrackerTests.swift @@ -110,4 +110,32 @@ struct GeohashChatActivityTrackerTests { #expect(tracker.messageCount(for: "9q8yy") == 0) #expect(tracker.mostActiveConversation(among: [channel("9q8yy", .city)]) == nil) } + + @Test + func staleGeohashesAreEvictedNotJustEmptied() { + let (tracker, advance) = makeTracker(window: 900) + tracker.recordChatMessage(geohash: "9q8yy", senderName: "a#1111", content: "old region", timestamp: baseDate) + #expect(tracker._trackedGeohashCountForTesting == 1) + + // Move well past the window, then record activity in a different + // geohash — this should sweep the now-stale "9q8yy" entry out of + // memory instead of leaving an empty-but-present dictionary entry. + advance(baseDate.addingTimeInterval(901)) + tracker.recordChatMessage(geohash: "9r1zz", senderName: "b#2222", content: "new region", timestamp: baseDate.addingTimeInterval(901)) + + #expect(tracker._trackedGeohashCountForTesting == 1) + #expect(tracker.messageCount(for: "9q8yy") == 0) + #expect(tracker.messageCount(for: "9r1zz") == 1) + } + + @Test + func activeGeohashSurvivesSweepEvenWhenMomentarilyEmpty() { + // The geohash currently being recorded to is exempt from the sweep, + // so recording its own first message never evicts itself. + let (tracker, _) = makeTracker(window: 900) + tracker.recordChatMessage(geohash: "9q8yy", senderName: "a#1111", content: "hi", timestamp: baseDate) + + #expect(tracker._trackedGeohashCountForTesting == 1) + #expect(tracker.messageCount(for: "9q8yy") == 1) + } }