From 6e9b85bcb3b924a073a9a6489990dcb946bdb70e Mon Sep 17 00:00:00 2001 From: dangershony Date: Sun, 2 Aug 2026 19:59:42 +0100 Subject: [PATCH] Fix intermittent infinite 'Loading project...' hang caused by EOSE tracking of never-connected relays - Only track relays whose websocket is actually running in EOSE/OK snapshots; a relay that never completed the WS upgrade never sends EOSE and its DisconnectionHappened fired before monitoring started, blocking completion forever - Add 30s watchdog to InvestView remote project load so the UI falls back to an error message instead of spinning indefinitely - Query discovery (purple pages) relays for NIP-65 relay lists in LookupRelayListForNPubs and include them in EOSE tracking; close REQ on discovery relays too - Pass a real end-of-stream callback from MergeProjectRelays so the relay-list subscription isn't closed on the first EOSE from any relay - Add Settings link to the standalone InvestView header and loading/error states so users can fix relay/indexer settings when loading fails --- .../Services/INostrCommunicationFactory.cs | 2 +- .../Services/IRelaySubscriptionsHandling.cs | 2 +- .../Services/NostrCommunicationFactory.cs | 28 ++++++----- .../Angor.Shared/Services/RelayService.cs | 13 +++-- .../Services/RelaySubscriptionsHandling.cs | 10 +++- .../Angor.Client/Pages/InvestView.razor | 49 +++++++++++++++++-- 6 files changed, 83 insertions(+), 21 deletions(-) diff --git a/src/shared/Angor.Shared/Services/INostrCommunicationFactory.cs b/src/shared/Angor.Shared/Services/INostrCommunicationFactory.cs index f75025558..8596e80e7 100644 --- a/src/shared/Angor.Shared/Services/INostrCommunicationFactory.cs +++ b/src/shared/Angor.Shared/Services/INostrCommunicationFactory.cs @@ -9,7 +9,7 @@ public interface INostrCommunicationFactory void CloseClientConnection(); int GetNumberOfRelaysConnected(); bool EoseEventReceivedOnAllRelays(string subscription); - bool MonitoringEoseReceivedOnSubscription(string subscription); + bool MonitoringEoseReceivedOnSubscription(string subscription, bool includeDiscoveryRelays = false); void ClearEoseReceivedOnSubscriptionMonitoring(string subscription); bool OkEventReceivedOnAllRelays(string eventId); void MonitoringOkReceivedOnSubscription(string eventId); diff --git a/src/shared/Angor.Shared/Services/IRelaySubscriptionsHandling.cs b/src/shared/Angor.Shared/Services/IRelaySubscriptionsHandling.cs index 6675a511d..36a6dcd6d 100644 --- a/src/shared/Angor.Shared/Services/IRelaySubscriptionsHandling.cs +++ b/src/shared/Angor.Shared/Services/IRelaySubscriptionsHandling.cs @@ -6,7 +6,7 @@ public interface IRelaySubscriptionsHandling { bool TryAddOKAction(string eventId, Action action); void HandleOkMessages(NostrOkResponse _); - bool TryAddEoseAction(string subscriptionName, Action action); + bool TryAddEoseAction(string subscriptionName, Action action, bool includeDiscoveryRelays = false); void HandleEoseMessages(NostrEoseResponse _); bool RelaySubscriptionAdded(string subscriptionKey); bool TryAddRelaySubscription(string subscriptionKey, IDisposable subscription, bool keepActive = false); diff --git a/src/shared/Angor.Shared/Services/NostrCommunicationFactory.cs b/src/shared/Angor.Shared/Services/NostrCommunicationFactory.cs index 3d36803d7..3362b1819 100644 --- a/src/shared/Angor.Shared/Services/NostrCommunicationFactory.cs +++ b/src/shared/Angor.Shared/Services/NostrCommunicationFactory.cs @@ -30,7 +30,7 @@ public NostrCommunicationFactory(ILogger clientLogger, ILo _okCalledOnSubscriptionClients = new(); } - private ConcurrentDictionary GetAllConnectedRelayNames() + private ConcurrentDictionary GetAllConnectedRelayNames(bool includeDiscoveryRelays = false) { var allRelays = new ConcurrentDictionary(); @@ -38,17 +38,23 @@ private ConcurrentDictionary GetAllConnectedRelayNames() { foreach (var client in _nostrMultiWebsocketClient.Clients) { - allRelays.TryAdd(client.Communicator.Name, 0); + // Only track relays whose websocket is actually running. A relay that never + // completed the WS upgrade will never send EOSE, and its DisconnectionHappened + // already fired before any subscription was monitored — so including it here + // would block the "all relays sent EOSE" completion check forever. + if (client.Communicator.IsRunning) + allRelays.TryAdd(client.Communicator.Name, 0); } } - // if (_nostrMultiWebsocketClientDiscovery != null) - // { - // foreach (var client in _nostrMultiWebsocketClientDiscovery.Clients) - // { - // allRelays.Add(client.Communicator.Name); - // } - // } + if (includeDiscoveryRelays && _nostrMultiWebsocketClientDiscovery != null) + { + foreach (var client in _nostrMultiWebsocketClientDiscovery.Clients) + { + if (client.Communicator.IsRunning) + allRelays.TryAdd(client.Communicator.Name, 0); + } + } return allRelays; } @@ -190,10 +196,10 @@ public bool EoseEventReceivedOnAllRelays(string subscription) return response; } - public bool MonitoringEoseReceivedOnSubscription(string subscription) + public bool MonitoringEoseReceivedOnSubscription(string subscription, bool includeDiscoveryRelays = false) { _logger.LogDebug($"Started monitoring subscription {subscription}"); - var relayNames = GetAllConnectedRelayNames(); + var relayNames = GetAllConnectedRelayNames(includeDiscoveryRelays); if (_eoseCalledOnSubscriptionClients.TryAdd(subscription, relayNames)) return true; diff --git a/src/shared/Angor.Shared/Services/RelayService.cs b/src/shared/Angor.Shared/Services/RelayService.cs index 0ca3744c5..6ab974709 100644 --- a/src/shared/Angor.Shared/Services/RelayService.cs +++ b/src/shared/Angor.Shared/Services/RelayService.cs @@ -380,12 +380,16 @@ public void LookupLatestProjects(Action> onResponseAction, Actio public void LookupRelayListForNPubs(Action> onResponse, Action onEndOfStream, params string[] npubs) { var client = _communicationFactory.GetOrCreateClient(_networkService); + // NIP-65 relay lists (kind 10002) are published to the discovery ("purple pages") relays, + // so query them alongside the regular relays — the account's relay list is often only there. + var discoveryClient = _communicationFactory.GetOrCreateDiscoveryClients(_networkService); var subscriptionKey = Guid.NewGuid().ToString().Replace("-", ""); if (!_subscriptionsHandling.RelaySubscriptionAdded(subscriptionKey)) { var subscription = client.Streams.EventStream + .Merge(discoveryClient.Streams.EventStream) .Where(_ => _.Subscription == subscriptionKey) .Where(_ => _.Event is not null) .Select(_ => _.Event) @@ -404,14 +408,17 @@ public void LookupRelayListForNPubs(Action> onRespon if (onEndOfStream != null) { - _subscriptionsHandling.TryAddEoseAction(subscriptionKey, onEndOfStream); + _subscriptionsHandling.TryAddEoseAction(subscriptionKey, onEndOfStream, includeDiscoveryRelays: true); } - client.Send(new NostrRequest(subscriptionKey, new NostrFilter + var request = new NostrRequest(subscriptionKey, new NostrFilter { Authors = npubs, Kinds = [NostrKind.RelayListMetadata], - })); + }); + + client.Send(request); + discoveryClient.Send(request); } public async Task FetchProfileMetadataAsync(string nostrPubKeyHex) diff --git a/src/shared/Angor.Shared/Services/RelaySubscriptionsHandling.cs b/src/shared/Angor.Shared/Services/RelaySubscriptionsHandling.cs index 1551a2e5d..b3d9f1c09 100644 --- a/src/shared/Angor.Shared/Services/RelaySubscriptionsHandling.cs +++ b/src/shared/Angor.Shared/Services/RelaySubscriptionsHandling.cs @@ -136,11 +136,11 @@ public void HandleOkMessages(NostrOkResponse okResponse) _communicationFactory.ClearOkReceivedOnSubscriptionMonitoring(okResponse.EventId); } - public bool TryAddEoseAction(string subscriptionName, Action action) + public bool TryAddEoseAction(string subscriptionName, Action action, bool includeDiscoveryRelays = false) { if (action == null) throw new ArgumentNullException(nameof(action)); - var add = _communicationFactory.MonitoringEoseReceivedOnSubscription(subscriptionName); + var add = _communicationFactory.MonitoringEoseReceivedOnSubscription(subscriptionName, includeDiscoveryRelays); if (!add) _logger.LogDebug($"Subscription {subscriptionName} is already being monitored"); @@ -190,6 +190,12 @@ public void CloseSubscription(string subscriptionKey) _communicationFactory .GetOrCreateClient(_networkService) .Send(new NostrCloseRequest(subscriptionKey)); + + // Some lookups (e.g. NIP-65 relay lists) also send the REQ to the discovery relays; + // closing there too is harmless when the subscription was never opened on them. + _communicationFactory + .GetOrCreateDiscoveryClients(_networkService) + .Send(new NostrCloseRequest(subscriptionKey)); subscription.Dispose(); relaySubscriptionsKeepActive.Remove(subscriptionKey, out _); diff --git a/src/webapp/Angor.Client/Pages/InvestView.razor b/src/webapp/Angor.Client/Pages/InvestView.razor index 44fe20355..712ffc549 100644 --- a/src/webapp/Angor.Client/Pages/InvestView.razor +++ b/src/webapp/Angor.Client/Pages/InvestView.razor @@ -55,7 +55,13 @@ Angor Angor Invest - @network.Name + + @network.Name + + + Settings + + @if (loadingProject) @@ -63,6 +69,9 @@

Loading project...

+

+ Taking too long? Check your relay and indexer settings. +

return; } @@ -74,8 +83,9 @@ @(loadError ?? "The project was not found.") - return; @@ -918,6 +928,7 @@ private Project? project; private bool loadingProject = true; + private static readonly TimeSpan ProjectLoadTimeout = TimeSpan.FromSeconds(30); private string? loadError; private bool buildSpinner; private bool investSpinner; @@ -1150,6 +1161,8 @@ /// private async Task LoadRemoteProjectAsync() { + StartProjectLoadWatchdog(); + try { var projectIndexerData = await _IndexerService.GetProjectByIdAsync(ProjectId); @@ -1228,6 +1241,36 @@ } } + /// + /// Overall timeout for the remote project load. The Nostr EOSE completion callback can be + /// lost when relays disconnect mid-flight (or never connect at all), which would leave the + /// page on the loading spinner forever — fall back to an error message instead. + /// + private void StartProjectLoadWatchdog() + { + _ = Task.Run(async () => + { + await Task.Delay(ProjectLoadTimeout); + + if (!loadingProject) + return; + + _Logger.LogWarning("Project load timed out after {Timeout}s — relays did not complete", ProjectLoadTimeout.TotalSeconds); + + await InvokeAsync(() => + { + if (!loadingProject) + return; + + if (project?.ProjectInfo == null) + loadError = "Could not load the project from the relays. Please check your connection and refresh the page."; + + loadingProject = false; + StateHasChanged(); + }); + }); + } + /// /// Validate the configured network against the project's NetworkName (from the Nostr event). /// Returns true when the networks match. When they don't: switches network automatically if @@ -1378,7 +1421,7 @@ _Logger.LogInformation("Merged {Count} relays from project profile", relays.Count); } }, - null, + () => _Logger.LogDebug("NIP-65 relay list lookup completed (EOSE from all relays incl. discovery)"), nostrPubKey); } catch (Exception ex)