From fa4b7a75186b8d3d911703e04665d2573e4911bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Wed, 28 Jan 2026 15:44:09 -0800 Subject: [PATCH] Wait for app to load the relay list and connect before loading universe view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../NostrNetworkManager.swift | 114 ++++++++++++++++++ .../Search/Models/SearchHomeModel.swift | 2 + .../Search/Views/SearchHomeView.swift | 10 +- 3 files changed, 118 insertions(+), 8 deletions(-) diff --git a/damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift b/damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift index 1ab911ff..959bbdbd 100644 --- a/damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift +++ b/damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift @@ -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] = [:] + /// 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? + var isResumed = false + + await withCheckedContinuation { (continuation: CheckedContinuation) 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 { diff --git a/damus/Features/Search/Models/SearchHomeModel.swift b/damus/Features/Search/Models/SearchHomeModel.swift index de0f4af9..7e964b39 100644 --- a/damus/Features/Search/Models/SearchHomeModel.swift +++ b/damus/Features/Search/Models/SearchHomeModel.swift @@ -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) } diff --git a/damus/Features/Search/Views/SearchHomeView.swift b/damus/Features/Search/Views/SearchHomeView.swift index 58f96835..eb4320bb 100644 --- a/damus/Features/Search/Views/SearchHomeView.swift +++ b/damus/Features/Search/Views/SearchHomeView.swift @@ -14,7 +14,6 @@ struct SearchHomeView: View { @StateObject var model: SearchHomeModel @State var search: String = "" @FocusState private var isFocused: Bool - @State var loadingTask: Task? 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() } } }