mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 03:45:20 +00:00
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>
This commit is contained in:
co-authored by
jack
Claude Fable 5
parent
93d01b8fa6
commit
75dd83d9cc
@@ -1,4 +1,5 @@
|
||||
import Testing
|
||||
import Combine
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
@@ -26,6 +27,41 @@ struct LocationNotesManagerTests {
|
||||
#expect(manager.errorMessage == String(localized: "location_notes.error.no_relays"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func noRelays_resubscribesWhenDirectoryRefreshes() async throws {
|
||||
var relays: [String] = []
|
||||
var subscribeCount = 0
|
||||
let directorySubject = PassthroughSubject<Void, Never>()
|
||||
let deps = LocationNotesDependencies(
|
||||
relayLookup: { _, _ in relays },
|
||||
subscribe: { _, _, _, _, _ in
|
||||
subscribeCount += 1
|
||||
},
|
||||
unsubscribe: { _ in },
|
||||
sendEvent: { _, _ in },
|
||||
deriveIdentity: { _ in try NostrIdentity.generate() },
|
||||
now: { Date() },
|
||||
relayDirectoryUpdates: directorySubject.eraseToAnyPublisher()
|
||||
)
|
||||
|
||||
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
||||
#expect(manager.state == .noRelays)
|
||||
#expect(subscribeCount == 0)
|
||||
|
||||
// Directory loads later (e.g. remote fetch finished after Tor came up).
|
||||
relays = ["wss://relay.one"]
|
||||
directorySubject.send(())
|
||||
|
||||
let deadline = Date().addingTimeInterval(1.0)
|
||||
while manager.state == .noRelays && Date() < deadline {
|
||||
try await Task.sleep(nanoseconds: 10_000_000)
|
||||
}
|
||||
|
||||
#expect(subscribeCount == 1)
|
||||
#expect(manager.state == .loading)
|
||||
#expect(manager.errorMessage == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func sendWithoutRelays_surfacesNoRelaysError() {
|
||||
var sendCalled = false
|
||||
|
||||
@@ -55,6 +55,47 @@ final class GeoRelayDirectoryTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
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: """
|
||||
|
||||
@@ -761,7 +761,12 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
}
|
||||
XCTAssertTrue(subscribed)
|
||||
|
||||
let timedOut = await waitUntil(timeout: 3.0) { eoseCount == 1 }
|
||||
// The fallback is scheduled but has not fired yet.
|
||||
XCTAssertEqual(context.scheduler.scheduled.first?.delay, TransportConfig.nostrSubscriptionEOSEFallbackSeconds)
|
||||
XCTAssertEqual(eoseCount, 0)
|
||||
|
||||
context.scheduler.runNext()
|
||||
let timedOut = await waitUntil { eoseCount == 1 }
|
||||
XCTAssertTrue(timedOut)
|
||||
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitEOSE(subscriptionID: "timeout")
|
||||
@@ -769,6 +774,192 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
XCTAssertEqual(eoseCount, 1)
|
||||
}
|
||||
|
||||
func test_eose_completesWhenRelayDisconnectsBeforeEOSE() async throws {
|
||||
let relayOne = "wss://eose-drop-one.example"
|
||||
let relayTwo = "wss://eose-drop-two.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
var eoseCount = 0
|
||||
|
||||
context.manager.subscribe(
|
||||
filter: makeFilter(),
|
||||
id: "eose-drop",
|
||||
relayUrls: [relayOne, relayTwo],
|
||||
handler: { _ in },
|
||||
onEOSE: { eoseCount += 1 }
|
||||
)
|
||||
|
||||
let subscribed = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayOne)?.sentStrings.count == 1 &&
|
||||
context.sessionFactory.latestConnection(for: relayTwo)?.sentStrings.count == 1
|
||||
}
|
||||
XCTAssertTrue(subscribed)
|
||||
|
||||
try context.sessionFactory.latestConnection(for: relayOne)?.emitEOSE(subscriptionID: "eose-drop")
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
XCTAssertEqual(eoseCount, 0)
|
||||
|
||||
context.sessionFactory.latestConnection(for: relayTwo)?.fail(
|
||||
error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)
|
||||
)
|
||||
let completed = await waitUntil { eoseCount == 1 }
|
||||
XCTAssertTrue(completed)
|
||||
}
|
||||
|
||||
func test_reconnect_replaysActiveSubscriptionsAndDeliversEvents() async throws {
|
||||
let relayURL = "wss://replay.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
var received: [NostrEvent] = []
|
||||
|
||||
context.manager.subscribe(
|
||||
filter: makeFilter(),
|
||||
id: "replay-sub",
|
||||
relayUrls: [relayURL],
|
||||
handler: { received.append($0) }
|
||||
)
|
||||
let subscribed = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.contains { $0.contains("replay-sub") } == true
|
||||
}
|
||||
XCTAssertTrue(subscribed)
|
||||
|
||||
// Drop the socket; the relay forgets the subscription with it.
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.fail(
|
||||
error: NSError(domain: NSURLErrorDomain, code: NSURLErrorNetworkConnectionLost)
|
||||
)
|
||||
let retryScheduled = await waitUntil { !context.scheduler.scheduled.isEmpty }
|
||||
XCTAssertTrue(retryScheduled)
|
||||
context.scheduler.runNext()
|
||||
|
||||
let replayed = await waitUntil {
|
||||
let connections = context.sessionFactory.connectionsByURL[relayURL] ?? []
|
||||
return connections.count == 2 &&
|
||||
connections.last?.sentStrings.contains { $0.contains("replay-sub") } == true
|
||||
}
|
||||
XCTAssertTrue(replayed)
|
||||
|
||||
let event = try makeSignedEvent(content: "after reconnect")
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "replay-sub", event: event)
|
||||
let delivered = await waitUntil { received.count == 1 }
|
||||
XCTAssertTrue(delivered)
|
||||
}
|
||||
|
||||
func test_disconnectThenConnect_restoresSubscriptions() async {
|
||||
let relayURL = "wss://restore.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
|
||||
context.manager.subscribe(filter: makeFilter(), id: "restore-sub", relayUrls: [relayURL], handler: { _ in })
|
||||
let subscribed = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.contains { $0.contains("restore-sub") } == true
|
||||
}
|
||||
XCTAssertTrue(subscribed)
|
||||
|
||||
// Background → foreground: connections reset, subscriptions must survive.
|
||||
context.manager.disconnect()
|
||||
context.manager.connect()
|
||||
|
||||
let resubscribed = await waitUntil {
|
||||
let connections = context.sessionFactory.connectionsByURL[relayURL] ?? []
|
||||
return connections.count == 2 &&
|
||||
connections.last?.sentStrings.contains { $0.contains("restore-sub") } == true
|
||||
}
|
||||
XCTAssertTrue(resubscribed)
|
||||
}
|
||||
|
||||
func test_subscriptionSendFailure_retriesOnReconnect() async {
|
||||
let relayURL = "wss://flaky-send.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
context.sessionFactory.sendErrorByURL[relayURL] = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)
|
||||
|
||||
context.manager.subscribe(filter: makeFilter(), id: "flaky-sub", relayUrls: [relayURL], handler: { _ in })
|
||||
let attempted = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.isEmpty == false
|
||||
}
|
||||
XCTAssertTrue(attempted)
|
||||
|
||||
// The REQ send failed; the subscription must survive for the next connection.
|
||||
context.sessionFactory.sendErrorByURL[relayURL] = nil
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.fail(
|
||||
error: NSError(domain: NSURLErrorDomain, code: NSURLErrorNetworkConnectionLost)
|
||||
)
|
||||
let retryScheduled = await waitUntil { !context.scheduler.scheduled.isEmpty }
|
||||
XCTAssertTrue(retryScheduled)
|
||||
context.scheduler.runNext()
|
||||
|
||||
let resubscribed = await waitUntil {
|
||||
let connections = context.sessionFactory.connectionsByURL[relayURL] ?? []
|
||||
return connections.count == 2 &&
|
||||
connections.last?.sentStrings.contains { $0.contains("flaky-sub") } == true
|
||||
}
|
||||
XCTAssertTrue(resubscribed)
|
||||
}
|
||||
|
||||
func test_staleSendCompletionFromDeadSocket_doesNotBlockReplayOnNextConnection() async {
|
||||
let relayURL = "wss://stale-completion.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
|
||||
context.manager.subscribe(filter: makeFilter(), id: "stale-sub", relayUrls: [relayURL], handler: { _ in })
|
||||
// The connection exists synchronously; its REQ flush lands on a later
|
||||
// main-queue tick, so deferring completions here is race-free.
|
||||
let connectionA = context.sessionFactory.latestConnection(for: relayURL)
|
||||
XCTAssertNotNil(connectionA)
|
||||
connectionA?.deferSendCompletions = true
|
||||
|
||||
let reqSent = await waitUntil {
|
||||
connectionA?.sentStrings.contains { $0.contains("stale-sub") } == true
|
||||
}
|
||||
XCTAssertTrue(reqSent)
|
||||
|
||||
// Socket dies while the REQ's send completion is still in flight.
|
||||
connectionA?.fail(error: NSError(domain: NSURLErrorDomain, code: NSURLErrorNetworkConnectionLost))
|
||||
let disconnected = await waitUntil {
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == false
|
||||
}
|
||||
XCTAssertTrue(disconnected)
|
||||
|
||||
// The stale success completion must not mark the subscription active.
|
||||
connectionA?.flushDeferredSendCompletions()
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
|
||||
context.scheduler.runNext()
|
||||
let replayed = await waitUntil {
|
||||
let connections = context.sessionFactory.connectionsByURL[relayURL] ?? []
|
||||
return connections.count == 2 &&
|
||||
connections.last?.sentStrings.contains { $0.contains("stale-sub") } == true
|
||||
}
|
||||
XCTAssertTrue(replayed)
|
||||
}
|
||||
|
||||
func test_permanentFailure_decaysAfterCooldownAndRetries() async {
|
||||
let relayURL = "wss://cooldown.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
context.sessionFactory.pingErrorByURL[relayURL] = NSError(
|
||||
domain: NSURLErrorDomain,
|
||||
code: NSURLErrorCannotFindHost,
|
||||
userInfo: [NSLocalizedDescriptionKey: "DNS failure"]
|
||||
)
|
||||
|
||||
context.manager.ensureConnections(to: [relayURL])
|
||||
let failed = await waitUntil {
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == TransportConfig.nostrRelayMaxReconnectAttempts
|
||||
}
|
||||
XCTAssertTrue(failed)
|
||||
|
||||
// Within the cooldown the relay is skipped.
|
||||
let countBefore = context.sessionFactory.requestedURLs.count
|
||||
context.manager.ensureConnections(to: [relayURL])
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
XCTAssertEqual(context.sessionFactory.requestedURLs.count, countBefore)
|
||||
|
||||
// After the cooldown it gets another chance and recovers.
|
||||
context.sessionFactory.pingErrorByURL[relayURL] = nil
|
||||
context.clock.now = context.clock.now.addingTimeInterval(TransportConfig.nostrRelayFailureCooldownSeconds + 1)
|
||||
context.manager.ensureConnections(to: [relayURL])
|
||||
let retried = await waitUntil {
|
||||
context.sessionFactory.requestedURLs.count == countBefore + 1 &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
|
||||
}
|
||||
XCTAssertTrue(retried)
|
||||
}
|
||||
|
||||
func test_receiveFailure_schedulesReconnectWithBackoff() async {
|
||||
let relayURL = "wss://retry.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
@@ -1222,9 +1413,22 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
||||
cancelCallCount += 1
|
||||
}
|
||||
|
||||
var deferSendCompletions = false
|
||||
private var deferredSendCompletions: [(Error?) -> Void] = []
|
||||
|
||||
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) {
|
||||
sentMessages.append(message)
|
||||
completionHandler(sendError)
|
||||
if deferSendCompletions {
|
||||
deferredSendCompletions.append(completionHandler)
|
||||
} else {
|
||||
completionHandler(sendError)
|
||||
}
|
||||
}
|
||||
|
||||
func flushDeferredSendCompletions() {
|
||||
let pending = deferredSendCompletions
|
||||
deferredSendCompletions = []
|
||||
pending.forEach { $0(sendError) }
|
||||
}
|
||||
|
||||
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
|
||||
|
||||
Reference in New Issue
Block a user