Wait for app to load the relay list and connect before loading universe view

This commit addresses a race condition that happened when the user
initializes the app on the universe view, where the loading function
would run before the relay list was fully loaded and connected, causing
the loading function to connect to an empty relay list.

The issue was fixed by introducing a call that allows callers to wait
for the app to connect to the network

Changelog-Fixed: Fixed issue where the app would occasionally launch an empty universe view
Closes: https://github.com/damus-io/damus/issues/3528
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
This commit is contained in:
Daniel D’Aquino
2026-01-28 15:44:09 -08:00
parent 438d537ff6
commit fa4b7a7518
3 changed files with 118 additions and 8 deletions

View File

@@ -35,6 +35,16 @@ class NostrNetworkManager {
let reader: SubscriptionManager
let profilesManager: ProfilesManager
/// Tracks whether the network manager has completed its initial connection
private var isConnected = false
/// A list of continuations waiting for connection to complete
///
/// We use a unique ID for each connection request so that multiple concurrent calls to `awaitConnection()`
/// can be properly tracked and resumed. This follows the pattern established in `RelayConnection` and `WalletModel`.
private var connectionContinuations: [UUID: CheckedContinuation<Void, Never>] = [:]
/// A lock to ensure thread-safe access to the continuations dictionary and connection state
private let continuationsLock = NSLock()
init(delegate: Delegate, addNdbToRelayPool: Bool = true) {
self.delegate = delegate
let pool = RelayPool(ndb: addNdbToRelayPool ? delegate.ndb : nil, keypair: delegate.keypair)
@@ -54,10 +64,110 @@ class NostrNetworkManager {
await self.userRelayList.connect() // Will load the user's list, apply it, and get RelayPool to connect to it.
await self.profilesManager.load()
await self.reader.startPreloader()
continuationsLock.lock()
isConnected = true
continuationsLock.unlock()
resumeAllConnectionContinuations()
}
/// Waits for the app to be connected to the network by checking for the next `connect()` call to complete
///
/// This method allows code to await the app to load the relay list and connect to it.
/// It uses Swift continuations to handle completion notifications from potentially different threads.
///
/// - Parameter timeout: Optional timeout duration (defaults to 30 seconds)
///
/// ## Usage
/// ```swift
/// await nostrNetworkManager.awaitConnection()
/// // Code here runs after connection is established
/// ```
///
/// ## Implementation notes
///
/// - Thread-safe: Can be called from any thread and will handle synchronization properly
/// - Multiple callers: Supports multiple concurrent calls, each tracked by a unique ID
/// - Timeout handling: Automatically resumes after timeout even if connection fails
/// - Short-circuits immediately if already connected, preventing unnecessary waiting
func awaitConnection(timeout: Duration = .seconds(30)) async {
// Short-circuit if already connected
continuationsLock.lock()
let alreadyConnected = isConnected
continuationsLock.unlock()
guard !alreadyConnected else {
return
}
let requestId = UUID()
var timeoutTask: Task<Void, Never>?
var isResumed = false
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
// Store the continuation in a thread-safe manner
continuationsLock.lock()
connectionContinuations[requestId] = continuation
continuationsLock.unlock()
// Set up timeout
timeoutTask = Task {
try? await Task.sleep(for: timeout)
if !isResumed {
self.resumeConnectionContinuation(requestId: requestId, isResumed: &isResumed)
}
}
}
timeoutTask?.cancel()
}
/// Resumes a connection continuation in a thread-safe manner
///
/// This can be called from any thread and ensures the continuation is only resumed once
///
/// - Parameters:
/// - requestId: The unique identifier for this connection request
/// - isResumed: Flag to track if the continuation has already been resumed
private func resumeConnectionContinuation(requestId: UUID, isResumed: inout Bool) {
continuationsLock.lock()
defer { continuationsLock.unlock() }
guard !isResumed, let continuation = connectionContinuations[requestId] else {
return
}
isResumed = true
connectionContinuations.removeValue(forKey: requestId)
continuation.resume()
}
/// Resumes all pending connection continuations in a thread-safe manner
///
/// This is useful for notifying all waiting callers when the connection is established
/// or when you need to unblock all pending connection requests.
///
/// This can be called from any thread and ensures all continuations are resumed safely.
private func resumeAllConnectionContinuations() {
continuationsLock.lock()
defer { continuationsLock.unlock() }
// Resume all pending continuations
for (_, continuation) in connectionContinuations {
continuation.resume()
}
// Clear the dictionary
connectionContinuations.removeAll()
}
func disconnectRelays() async {
await self.pool.disconnect()
continuationsLock.lock()
isConnected = false
continuationsLock.unlock()
}
func handleAppBackgroundRequest() async {
@@ -88,6 +198,10 @@ class NostrNetworkManager {
for await value in group { continue }
await pool.close()
}
continuationsLock.lock()
isConnected = false
continuationsLock.unlock()
}
func ping() async {

View File

@@ -55,6 +55,8 @@ class SearchHomeModel: ObservableObject {
DispatchQueue.main.async {
self.loading = true
}
await damus_state.nostrNetwork.awaitConnection()
let to_relays = await damus_state.nostrNetwork.ourRelayDescriptors
.map { $0.url }
.filter { !damus_state.relay_filters.is_filtered(timeline: .search, relay_id: $0) }

View File

@@ -14,7 +14,6 @@ struct SearchHomeView: View {
@StateObject var model: SearchHomeModel
@State var search: String = ""
@FocusState private var isFocused: Bool
@State var loadingTask: Task<Void, Never>?
func content_filter(_ fstate: FilterState) -> ((NostrEvent) -> Bool) {
var filters = ContentFilters.defaults(damus_state: damus_state)
@@ -118,13 +117,8 @@ struct SearchHomeView: View {
.onReceive(handle_notify(.new_mutes)) { _ in
self.model.filter_muted()
}
.onAppear {
if model.events.events.isEmpty {
loadingTask = Task { await model.load() }
}
}
.onDisappear {
loadingTask?.cancel()
.task {
await model.load()
}
}
}