What Is The Correct Way of Closing / Disconnecting An ASP.NET Core signalR Client Connection?

11.2k views Asked by At

I am a new user struggling to close a secondary signalR client gracefully from an ASP.NET Core Blazor Server Page.

I am setting up a secondary signalR client connection on first render of a Blazor Server Page. I am trying to close this secondary signalR client connection when the Page is closed via the browser tab.

At the time of writing DisposeAsync does not seem to be triggered when the page is closed via the browser tab. However, the Dispose method IS triggered. Furthermore in Safari 13.0.5 the Dispose method is not triggered when the browser tab is closed? Opera, Firefox and Chrome all have Dispose triggered upon closing the browser tab. Fixed this by updating Safari to v14.0 (15610.1.28.9, 15610) via macOS Catalina v10.15.7.

Currently, I am calling DisposeAsync from Dispose to close the signalR connection. I am closing the client connection using the following code:

...
Logger.LogInformation("Closing secondary signalR connection...");
await hubConnection.StopAsync();
Logger.LogInformation("Closed secondary signalR connection");
...

The StopAsync method appears to block, i.e. no message is output for "Closed secondary signalR connection". Although, the OnDisconnectedAsync handler of my server hub displays that the connection has being disconnected. This is similar to the behaviour described in this issue.

How do I correctly dispose of a signalR connection in ASP.NET Core 3.1?

Full code listings are shown below:

Disposing Of signalR Connection

 #region Dispose
        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }


        /// <summary>
        /// Clear secondary signalR Closed event handler and stop the
        /// secondary signalR connection
        /// </summary>
        /// <remarks>
        /// ASP.NET Core Release Candidate 5 calls DisposeAsync when 
        /// navigating away from a Blazor Server page. Until the 
        /// release is stable DisposeAsync will have to be triggered from
        /// Dispose. Sadly, this means having to use GetAwaiter().GetResult()
        /// in Dispose().
        /// However, providing DisposeAsync() now makes the migration easier
        /// https://github.com/dotnet/aspnetcore/issues/26737
        /// https://github.com/dotnet/aspnetcore/issues/9960
        /// https://github.com/dotnet/aspnetcore/milestone/57?closed=1
        /// </remarks>
        protected virtual void Dispose(bool disposing)
        {
            if (disposed)
                return;

            if (disposing)
            {
                Logger.LogInformation("Index.razor page is disposing...");

                try
                {
                    if (hubConnection != null)
                    {
                        Logger.LogInformation("Removing signalR client event handlers...");
                        hubConnection.Closed -= CloseHandler;
                    }

                    // Until ASP.NET Core 5 is released in November
                    // trigger DisposeAsync(). See docstring and DiposeAsync() below.
                    // not ideal, but having to use GetAwaiter().GetResult() until
                    // forthcoming release of ASP.NET Core 5 for the introduction
                    // of triggering DisposeAsync on pages that implement IAsyncDisposable
                    DisposeAsync().GetAwaiter().GetResult();
                }
                catch (Exception exception)
                {
                    Logger.LogError($"Exception encountered while disposing Index.razor page :: {exception.Message}");
                }
            }

            disposed = true;
        }


        /// <summary>
        /// Dispose the secondary backend signalR connection
        /// </summary>
        /// <remarks>
        /// ASP.NET Core Release Candidate 5 adds DisposeAsync when 
        /// navigating away from a Blazor Server page. Until the 
        /// release is stable DisposeAsync will have to be triggered from
        /// Dispose. Sadly, this means having to use GetAwaiter().GetResult()
        /// in Dispose().
        /// However, providing DisposeAsync() now makes the migration easier
        /// https://github.com/dotnet/aspnetcore/issues/26737
        /// https://github.com/dotnet/aspnetcore/issues/9960
        /// https://github.com/dotnet/aspnetcore/milestone/57?closed=1
        /// </remarks>
        public async virtual ValueTask DisposeAsync()
        {
            try
            {
                if (hubConnection != null)
                {
                    Logger.LogInformation("Closing secondary signalR connection...");
                    await hubConnection.StopAsync();
                    Logger.LogInformation("Closed secondary signalR connection");
                }
                // Dispose(); When migrated to ASP.NET Core 5 let DisposeAsync trigger Dispose
            }
            catch (Exception exception)
            {
                Logger.LogInformation($"Exception encountered wwhile stopping secondary signalR connection :: {exception.Message}");
            }
        }
        #endregion

Full Code For Blazor Server Page

using System;
using System.Threading.Tasks;

using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

using WebApp.Data;
using WebApp.Data.Serializers.Converters;
using WebApp.Data.Serializers.Converters.Visitors;
using WebApp.Repository.Contracts;



namespace WebApp.Pages
{
    public partial class Index : IAsyncDisposable, IDisposable
    {
        private HubConnection hubConnection;
        public bool IsConnected => hubConnection.State == HubConnectionState.Connected;
        private bool disposed = false;


        [Inject]
        public NavigationManager NavigationManager { get; set; }
        [Inject]
        public IMotionDetectionRepository Repository { get; set; }
        [Inject]
        public ILogger<MotionDetectionConverter> LoggerMotionDetection { get; set; }
        [Inject]
        public ILogger<MotionInfoConverter> LoggerMotionInfo { get; set; }
        [Inject]
        public ILogger<JsonVisitor> LoggerJsonVisitor { get; set; }
        [Inject]
        public ILogger<Index> Logger { get; set; }


        #region Dispose
        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }


        /// <summary>
        /// Clear secondary signalR Closed event handler and stop the
        /// secondary signalR connection
        /// </summary>
        /// <remarks>
        /// ASP.NET Core Release Candidate 5 calls DisposeAsync when 
        /// navigating away from a Blazor Server page. Until the 
        /// release is stable DisposeAsync will have to be triggered from
        /// Dispose. Sadly, this means having to use GetAwaiter().GetResult()
        /// in Dispose().
        /// However, providing DisposeAsync() now makes the migration easier
        /// https://github.com/dotnet/aspnetcore/issues/26737
        /// https://github.com/dotnet/aspnetcore/issues/9960
        /// https://github.com/dotnet/aspnetcore/milestone/57?closed=1
        /// </remarks>
        protected virtual void Dispose(bool disposing)
        {
            if (disposed)
                return;

            if (disposing)
            {
                Logger.LogInformation("Index.razor page is disposing...");

                try
                {
                    if (hubConnection != null)
                    {
                        Logger.LogInformation("Removing signalR client event handlers...");
                        hubConnection.Closed -= CloseHandler;
                    }

                    // Until ASP.NET Core 5 is released in November
                    // trigger DisposeAsync(). See docstring and DiposeAsync() below.
                    // not ideal, but having to use GetAwaiter().GetResult() until
                    // forthcoming release of ASP.NET Core 5 for the introduction
                    // of triggering DisposeAsync on pages that implement IAsyncDisposable
                    DisposeAsync().GetAwaiter().GetResult();
                }
                catch (Exception exception)
                {
                    Logger.LogError($"Exception encountered while disposing Index.razor page :: {exception.Message}");
                }
            }

            disposed = true;
        }


        /// <summary>
        /// Dispose the secondary backend signalR connection
        /// </summary>
        /// <remarks>
        /// ASP.NET Core Release Candidate 5 adds DisposeAsync when 
        /// navigating away from a Blazor Server page. Until the 
        /// release is stable DisposeAsync will have to be triggered from
        /// Dispose. Sadly, this means having to use GetAwaiter().GetResult()
        /// in Dispose().
        /// However, providing DisposeAsync() now makes the migration easier
        /// https://github.com/dotnet/aspnetcore/issues/26737
        /// https://github.com/dotnet/aspnetcore/issues/9960
        /// https://github.com/dotnet/aspnetcore/milestone/57?closed=1
        /// </remarks>
        public async virtual ValueTask DisposeAsync()
        {
            try
            {
                if (hubConnection != null)
                {
                    Logger.LogInformation("Closing secondary signalR connection...");
                    await hubConnection.StopAsync();
                    Logger.LogInformation("Closed secondary signalR connection");
                }
                // Dispose(); When migrated to ASP.NET Core 5 let DisposeAsync trigger Dispose
            }
            catch (Exception exception)
            {
                Logger.LogInformation($"Exception encountered wwhile stopping secondary signalR connection :: {exception.Message}");
            }
        }
        #endregion


        #region ComponentBase

        /// <summary>
        /// Connect to the secondary signalR hub after rendering.
        /// Perform on the first render. 
        /// </summary>
        /// <remarks>
        /// This could have been performed in OnInitializedAsync but
        /// that method gets executed twice when server prerendering is used.
        /// </remarks>
        protected override async Task OnAfterRenderAsync(bool firstRender)
        {
            if (firstRender)
            {
                var hubUrl = NavigationManager.BaseUri.TrimEnd('/') + "/motionhub";

                try
                {
                    Logger.LogInformation("Index.razor page is performing initial render, connecting to secondary signalR hub");

                    hubConnection = new HubConnectionBuilder()
                        .WithUrl(hubUrl)
                        .ConfigureLogging(logging =>
                        {
                            logging.AddConsole();
                            logging.AddFilter("Microsoft.AspNetCore.SignalR", LogLevel.Information);
                        })
                        .AddJsonProtocol(options =>
                        {
                            options.PayloadSerializerOptions = JsonConvertersFactory.CreateDefaultJsonConverters(LoggerMotionDetection, LoggerMotionInfo, LoggerJsonVisitor);
                        })
                        .Build();

                    hubConnection.On<MotionDetection>("ReceiveMotionDetection", ReceiveMessage);
                    hubConnection.Closed += CloseHandler;

                    Logger.LogInformation("Starting HubConnection");

                    await hubConnection.StartAsync();

                    Logger.LogInformation("Index Razor Page initialised, listening on signalR hub url => " + hubUrl.ToString());
                }
                catch (Exception e)
                {
                    Logger.LogError(e, "Encountered exception => " + e);
                }
            }
        }

        protected override async Task OnInitializedAsync()
        {
            await Task.CompletedTask;
        }
        #endregion


        #region signalR

        /// <summary>Log signalR connection closing</summary>
        /// <param name="exception">
        /// If an exception occurred while closing then this argument describes the exception
        /// If the signaR connection was closed intentionally by client or server, then this
        /// argument is null
        /// </param>
        private Task CloseHandler(Exception exception)
        {
            if (exception == null)
            {
                Logger.LogInformation("signalR client connection closed");
            }
            else
            {
                Logger.LogInformation($"signalR client closed due to error => {exception.Message}");
            }

            return Task.CompletedTask;
        }

        /// <summary>
        /// Add motion detection notification to repository
        /// </summary>
        /// <param name="message">Motion detection received via signalR</param>
        private void ReceiveMessage(MotionDetection message)
        {
            try
            {
                Logger.LogInformation("Motion detection message received");

                Repository.AddItem(message);

                StateHasChanged();
            }
            catch (Exception ex)
            {
                Logger.LogError(ex, "An exception was encountered => " + ex.ToString());
            }
        }
        #endregion
    }
}

signalR Server Hub

using System;
using System.Threading.Tasks;

using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;


namespace WebApp.Realtime.SignalR
{
    /// <summary>
    /// This represents endpoints available on the server, available for the
    /// clients to call
    /// </summary>
    public class MotionHub : Hub<IMotion>
    {
        private bool _disposed = false;
        public ILogger<MotionHub> Logger { get; set; }

        public MotionHub(ILogger<MotionHub> logger) : base()
        {
            Logger = logger;
        }


        public override async Task OnConnectedAsync()
        {
            Logger.LogInformation($"OnConnectedAsync => Connection ID={Context.ConnectionId} : User={Context.User.Identity.Name}");
            await base.OnConnectedAsync();
        }

        public override async Task OnDisconnectedAsync(Exception exception)
        {
            if (exception != null)
            {
                Logger.LogInformation($"OnDisconnectedAsync => Connection ID={Context.ConnectionId} : User={Context.User.Identity.Name} : Exception={exception.Message}");
            }
            else
            {
                Logger.LogInformation($"OnDisconnectedAsync => Connection ID={Context.ConnectionId} : User={Context.User.Identity.Name}");
            }

            await base.OnDisconnectedAsync(exception);
        }

        // Protected implementation of Dispose pattern.
        protected override void Dispose(bool disposing)
        {
            if (_disposed)
            {
                return;
            }

            _disposed = true;

            // Call base class implementation.
            base.Dispose(disposing);
        }
    }
}
1

There are 1 answers

1
anon_dcs3spp On BEST ANSWER

Fixed with help from ASP.NET Core Github Discussions.

Within the Dispose method replaced DisposeAsync().GetAwaiter().GetResult(); to _ = DisposeAsync(); This calls DiposeAsync() without awaiting the task result.

Also updated my code that stops the hub connection:

  try { await hubConnection.StopAsync(); }
  finally
  {
    await hubConnection.DisposeAsync();
  }

Within DisposeAsync the StopAsync call on the HubConnection no longer blocks and the connection closes gracefully.