Files
bitchat/bitchatTests/Nostr/GeoRelayDirectoryTests.swift
T
75dd83d9cc Make location notes robust: durable relay subscriptions, failure decay, auto-recovery (#1333)
* Make location notes robust: durable relay subscriptions, failure decay, auto-recovery

Location notes (and geohash chat / DMs) intermittently stopped showing
events because the Nostr relay layer lost subscriptions and blacklisted
relays:

- Replay active subscriptions on every relay (re)connect. Relays drop
  REQs with the socket; previously a drop silently killed the
  subscription on that relay for the rest of the session. Durable
  subscription intent now also survives disconnect()/resetAllConnections
  (background -> foreground).
- Keep failed REQ sends queued instead of dropping them.
- Raise the EOSE fallback from a fixed 2s Timer to a 10s injected
  schedule (Tor needs more than 2s), and settle EOSE trackers when a
  relay disconnects before answering so initial load doesn't stall.
- Decay "permanently failed" relay markings after a 10-minute cooldown;
  previously ~9 minutes of outage (or one DNS hiccup) excluded a relay
  until app restart, with nothing resetting it on macOS.
- Make geo relay selection deterministic (distance, then host) so
  publishers and subscribers with the same directory agree on relays.
- Post .geoRelayDirectoryDidRefresh after a directory fetch and let
  LocationNotesManager auto-resubscribe out of the "no relays" state
  instead of requiring a manual retry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Guard subscription activation on connection identity

A REQ send completion from a dead socket could land after
handleDisconnection cleared the relay's subscriptions and re-mark the
subscription active, making the next connection skip the durable replay
and leave that relay silent. Only mark a subscription active if the
completing socket is still the relay's live connection.

Regression test defers send completions in the mock so the stale
completion deterministically interleaves between disconnect and
reconnect.

Addresses Codex review on #1333.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:28:23 +01:00

458 lines
15 KiB
Swift

import Foundation
import Tor
import XCTest
@testable import bitchat
@MainActor
final class GeoRelayDirectoryTests: XCTestCase {
func test_parseCSV_normalizesRelaySchemesAndDeduplicatesEntries() {
let csv = """
relay url,lat,lon
wss://one.example/,10,20
https://one.example,10,20
wss://one.example:443/,10,20
http://two.example/,11,21
wss://two.example:443,11,21
invalid row
ws://three.example,not-a-lat,22
"""
let parsed = Set(GeoRelayDirectory.parseCSV(csv))
XCTAssertEqual(
parsed,
Set([
GeoRelayDirectory.Entry(host: "one.example", lat: 10, lon: 20),
GeoRelayDirectory.Entry(host: "two.example", lat: 11, lon: 21)
])
)
}
func test_closestRelays_sortsByDistanceForLatLonAndGeohash() {
let harness = makeHarness(
cacheCSV: """
relay url,lat,lon
close.example,37.7749,-122.4194
medium.example,34.0522,-118.2437
far.example,40.7128,-74.0060
"""
)
let directory = GeoRelayDirectory(dependencies: harness.dependencies)
XCTAssertEqual(
directory.closestRelays(toLat: 37.78, lon: -122.41, count: 2),
["wss://close.example", "wss://medium.example"]
)
XCTAssertEqual(
directory.closestRelays(toLat: 37.78, lon: -122.41, count: 10),
["wss://close.example", "wss://medium.example", "wss://far.example"]
)
let geohash = Geohash.encode(latitude: 37.78, longitude: -122.41, precision: 6)
XCTAssertEqual(
directory.closestRelays(toGeohash: geohash, count: 2),
["wss://close.example", "wss://medium.example"]
)
}
func test_closestRelays_breaksDistanceTiesDeterministicallyByHost() {
// Same coordinates for all entries: selection must still be stable so
// publishers and subscribers using the same directory agree on relays.
let harness = makeHarness(
cacheCSV: """
relay url,lat,lon
zeta.example,10,10
alpha.example,10,10
mike.example,10,10
"""
)
let directory = GeoRelayDirectory(dependencies: harness.dependencies)
XCTAssertEqual(
directory.closestRelays(toLat: 10, lon: 10, count: 2),
["wss://alpha.example", "wss://mike.example"]
)
}
func test_fetchSuccess_postsDirectoryRefreshNotification() async {
let harness = makeHarness(fetchCSV: """
relay url,lat,lon
notify.example,1,2
""")
let directory = GeoRelayDirectory(dependencies: harness.dependencies)
var notified = 0
let observer = harness.notificationCenter.addObserver(
forName: .geoRelayDirectoryDidRefresh,
object: nil,
queue: .main
) { _ in
notified += 1
}
defer { harness.notificationCenter.removeObserver(observer) }
directory.prefetchIfNeeded()
let refreshed = await waitUntil { notified == 1 }
XCTAssertTrue(refreshed)
}
func test_loadLocalEntries_prefersCacheThenBundleThenWorkingDirectory() {
let cacheHarness = makeHarness(
cacheCSV: """
relay url,lat,lon
cache.example,1,1
""",
bundleCSV: """
relay url,lat,lon
bundle.example,2,2
""",
workingDirectoryCSV: """
relay url,lat,lon
cwd.example,3,3
"""
)
XCTAssertEqual(
GeoRelayDirectory(dependencies: cacheHarness.dependencies).entries,
[GeoRelayDirectory.Entry(host: "cache.example", lat: 1, lon: 1)]
)
let bundleHarness = makeHarness(
cacheCSV: "invalid",
bundleCSV: """
relay url,lat,lon
bundle.example,2,2
""",
workingDirectoryCSV: """
relay url,lat,lon
cwd.example,3,3
"""
)
XCTAssertEqual(
GeoRelayDirectory(dependencies: bundleHarness.dependencies).entries,
[GeoRelayDirectory.Entry(host: "bundle.example", lat: 2, lon: 2)]
)
let cwdHarness = makeHarness(
cacheCSV: nil,
bundleCSV: "invalid",
workingDirectoryCSV: """
relay url,lat,lon
cwd.example,3,3
"""
)
XCTAssertEqual(
GeoRelayDirectory(dependencies: cwdHarness.dependencies).entries,
[GeoRelayDirectory.Entry(host: "cwd.example", lat: 3, lon: 3)]
)
}
func test_prefetchIfNeeded_skipsWhenFetchIntervalHasNotElapsed() async {
let harness = makeHarness(fetchCSV: """
relay url,lat,lon
one.example,1,1
""")
harness.userDefaults.set(harness.clock.now, forKey: "georelay.lastFetchAt")
let directory = GeoRelayDirectory(dependencies: harness.dependencies)
directory.prefetchIfNeeded()
try? await Task.sleep(nanoseconds: 20_000_000)
let requestCount = await harness.fetcher.recordedRequestCount()
XCTAssertEqual(requestCount, 0)
XCTAssertFalse(directory.debugHasRetryTask)
}
func test_prefetchIfNeeded_successUpdatesEntriesPersistsCacheAndSkipsImmediateForcedRefetch() async {
let csv = """
relay url,lat,lon
refreshed.example,12,34
"""
let harness = makeHarness(fetchCSV: csv)
let directory = GeoRelayDirectory(dependencies: harness.dependencies)
directory.prefetchIfNeeded()
let refreshed = await waitUntil {
directory.entries == [GeoRelayDirectory.Entry(host: "refreshed.example", lat: 12, lon: 34)]
}
XCTAssertTrue(refreshed)
let requestCount = await harness.fetcher.recordedRequestCount()
XCTAssertEqual(requestCount, 1)
XCTAssertEqual(harness.fileStore.dataByURL[harness.cacheURL], csv.data(using: .utf8))
XCTAssertEqual(harness.userDefaults.object(forKey: "georelay.lastFetchAt") as? Date, harness.clock.now)
XCTAssertEqual(directory.debugRetryAttempt, 0)
XCTAssertFalse(directory.debugHasRetryTask)
directory.prefetchIfNeeded(force: true)
try? await Task.sleep(nanoseconds: 20_000_000)
let forcedRequestCount = await harness.fetcher.recordedRequestCount()
XCTAssertEqual(forcedRequestCount, 1)
}
func test_prefetchIfNeeded_runsRemoteFetchOffMainThread() async {
var factoryThreadFlags: [Bool] = []
let threadRecorder = MainThreadRecorder()
let harness = makeHarness(
fetchCSV: """
relay url,lat,lon
background.example,8,9
""",
fetchFactoryObserver: {
factoryThreadFlags.append(isExecutingOnMainThread())
},
fetchObserver: {
await threadRecorder.record(isExecutingOnMainThread())
}
)
let directory = GeoRelayDirectory(dependencies: harness.dependencies)
directory.prefetchIfNeeded()
let refreshed = await waitUntil {
directory.entries == [GeoRelayDirectory.Entry(host: "background.example", lat: 8, lon: 9)]
}
XCTAssertTrue(refreshed)
XCTAssertEqual(factoryThreadFlags, [true])
let recordedValues = await threadRecorder.recordedValues()
XCTAssertEqual(recordedValues, [false])
}
func test_prefetchIfNeeded_failureSchedulesRetryAndRecoversOnNextFetch() async {
let csv = """
relay url,lat,lon
retry.example,5,6
"""
let harness = makeHarness(
fetchResults: [
.failure(GeoRelayTestError.network),
.success(csv.data(using: .utf8)!)
]
)
let directory = GeoRelayDirectory(dependencies: harness.dependencies)
directory.prefetchIfNeeded()
let recovered = await waitUntil {
directory.entries == [GeoRelayDirectory.Entry(host: "retry.example", lat: 5, lon: 6)]
}
XCTAssertTrue(recovered)
let requestCount = await harness.fetcher.recordedRequestCount()
let retryDelays = await harness.retryRecorder.recordedDelays()
XCTAssertEqual(requestCount, 2)
XCTAssertEqual(retryDelays, [5])
XCTAssertEqual(directory.debugRetryAttempt, 0)
XCTAssertFalse(directory.debugHasRetryTask)
}
func test_observers_triggerPrefetchesForTorReadyAndAppActivation() async {
let activeNotification = Notification.Name("GeoRelayDirectoryTests.didBecomeActive")
let harness = makeHarness(
fetchCSV: """
relay url,lat,lon
observer.example,1,2
""",
autoStart: true,
activeNotificationName: activeNotification
)
var directory: GeoRelayDirectory? = GeoRelayDirectory(dependencies: harness.dependencies)
let initialFetch = await waitUntil {
await harness.fetcher.recordedRequestCount() == 1
}
XCTAssertTrue(initialFetch)
XCTAssertEqual(directory?.debugObserverCount, 2)
harness.clock.now = harness.clock.now.addingTimeInterval(6)
harness.notificationCenter.post(name: .TorDidBecomeReady, object: nil)
let torTriggered = await waitUntil {
await harness.fetcher.recordedRequestCount() == 2
}
XCTAssertTrue(torTriggered)
harness.clock.now = harness.clock.now.addingTimeInterval(61)
harness.notificationCenter.post(name: activeNotification, object: nil)
let activeTriggered = await waitUntil {
await harness.fetcher.recordedRequestCount() == 3
}
XCTAssertTrue(activeTriggered)
weak var weakDirectory: GeoRelayDirectory?
weakDirectory = directory
directory = nil
XCTAssertNil(weakDirectory)
}
private func makeHarness(
cacheCSV: String? = nil,
bundleCSV: String? = nil,
workingDirectoryCSV: String? = nil,
fetchCSV: String? = nil,
fetchResults: [Result<Data, Error>] = [],
fetchFactoryObserver: (@MainActor @Sendable () -> Void)? = nil,
fetchObserver: (@Sendable () async -> Void)? = nil,
autoStart: Bool = false,
activeNotificationName: Notification.Name? = nil
) -> GeoRelayHarness {
let userDefaultsSuite = "GeoRelayDirectoryTests.\(UUID().uuidString)"
let userDefaults = UserDefaults(suiteName: userDefaultsSuite)!
userDefaults.removePersistentDomain(forName: userDefaultsSuite)
let notificationCenter = NotificationCenter()
let clock = MutableGeoClock(now: Date(timeIntervalSince1970: 1_700_000_000))
let fileStore = InMemoryFileStore()
let cacheURL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString)-cache.csv")
let bundleURL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString)-bundle.csv")
let cwd = "/tmp/\(UUID().uuidString)-cwd"
let cwdURL = URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")
if let cacheCSV {
fileStore.dataByURL[cacheURL] = Data(cacheCSV.utf8)
}
if let bundleCSV {
fileStore.dataByURL[bundleURL] = Data(bundleCSV.utf8)
}
if let workingDirectoryCSV {
fileStore.dataByURL[cwdURL] = Data(workingDirectoryCSV.utf8)
}
let defaultFetchData = Data((fetchCSV ?? bundleCSV ?? cacheCSV ?? "relay url,lat,lon\nfallback.example,0,0\n").utf8)
let fetcher = FetchProbe(responses: fetchResults, defaultData: defaultFetchData)
let retryRecorder = RetryDelayRecorder()
let dependencies = GeoRelayDirectoryDependencies(
userDefaults: userDefaults,
notificationCenter: notificationCenter,
now: { clock.now },
remoteURL: URL(string: "https://example.com/nostr_relays.csv")!,
fetchInterval: 60,
refreshCheckInterval: 0,
retryInitialSeconds: 5,
retryMaxSeconds: 40,
awaitTorReady: { true },
makeFetchData: {
fetchFactoryObserver?()
return { request in
await fetchObserver?()
return try await fetcher.fetch(request)
}
},
readData: { url in
fileStore.dataByURL[url]
},
writeData: { data, url in
fileStore.dataByURL[url] = data
},
cacheURL: { cacheURL },
bundledCSVURLs: bundleCSV == nil ? { [] } : { [bundleURL] },
currentDirectoryPath: workingDirectoryCSV == nil ? { nil } : { cwd },
retrySleep: { delay in
await retryRecorder.record(delay)
},
activeNotificationName: activeNotificationName,
autoStart: autoStart
)
return GeoRelayHarness(
dependencies: dependencies,
clock: clock,
fileStore: fileStore,
fetcher: fetcher,
retryRecorder: retryRecorder,
userDefaults: userDefaults,
notificationCenter: notificationCenter,
cacheURL: cacheURL
)
}
private func waitUntil(
timeout: TimeInterval = 1.0,
condition: @escaping @MainActor () async -> Bool
) async -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if await condition() {
return true
}
try? await Task.sleep(nanoseconds: 10_000_000)
}
return await condition()
}
}
private struct GeoRelayHarness {
let dependencies: GeoRelayDirectoryDependencies
let clock: MutableGeoClock
let fileStore: InMemoryFileStore
let fetcher: FetchProbe
let retryRecorder: RetryDelayRecorder
let userDefaults: UserDefaults
let notificationCenter: NotificationCenter
let cacheURL: URL
}
private final class MutableGeoClock {
var now: Date
init(now: Date) {
self.now = now
}
}
private final class InMemoryFileStore {
var dataByURL: [URL: Data] = [:]
}
private actor FetchProbe {
private var responses: [Result<Data, Error>]
private let defaultData: Data
private(set) var requestCount = 0
init(responses: [Result<Data, Error>], defaultData: Data) {
self.responses = responses
self.defaultData = defaultData
}
func fetch(_ request: URLRequest) async throws -> Data {
_ = request
requestCount += 1
if !responses.isEmpty {
return try responses.removeFirst().get()
}
return defaultData
}
func recordedRequestCount() -> Int {
requestCount
}
}
private actor RetryDelayRecorder {
private(set) var delays: [TimeInterval] = []
func record(_ delay: TimeInterval) {
delays.append(delay)
}
func recordedDelays() -> [TimeInterval] {
delays
}
}
private actor MainThreadRecorder {
private var values: [Bool] = []
func record(_ value: Bool) {
values.append(value)
}
func recordedValues() -> [Bool] {
values
}
}
private enum GeoRelayTestError: Error {
case network
}
private func isExecutingOnMainThread() -> Bool {
Thread.isMainThread
}