this is starting to look scary...
On Wed, Jan 28, 2026 at 03:44:09PM -0800, Daniel D’Aquino wrote:
>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 <
dan...@daquino.me>
>
>Closes:
https://github.com/damus-io/damus/pull/3553
>---
> .../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 1ab911ffc..959bbdbdc 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<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 {
>diff --git a/damus/Features/Search/Models/SearchHomeModel.swift b/damus/Features/Search/Models/SearchHomeModel.swift
>index de0f4af96..7e964b39c 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 58f968357..eb4320bb7 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<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()
> }
> }
> }